feat(tui/theme): add AI-powered custom theme creation flow (prompt -> streaming -> preview -> save); persist label + colors, show custom label in Overview; include pseudo-row in Themes list; fix paste routing for theme form

This commit is contained in:
James Peter
2025-09-10 22:21:16 +10:00
parent 998d3db9a5
commit a59fba9280
4 changed files with 537 additions and 39 deletions
+65
View File
@@ -4,6 +4,7 @@ use crate::config_types::BrowserConfig;
use crate::config_types::History;
use crate::config_types::GithubConfig;
use crate::config_types::ThemeName;
use crate::config_types::ThemeColors;
use crate::config_types::McpServerConfig;
use crate::config_types::SandboxWorkspaceWrite;
use crate::config_types::ShellEnvironmentPolicy;
@@ -459,6 +460,70 @@ pub fn set_custom_spinner(
Ok(())
}
/// Save or update a custom theme with a display `label` and color overrides
/// under `[tui.theme]`, and set it active by writing `[tui.theme].name = "custom"`.
pub fn set_custom_theme(
codex_home: &Path,
label: &str,
colors: &ThemeColors,
) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let mut doc = match std::fs::read_to_string(&config_path) {
Ok(s) => s.parse::<DocumentMut>()?,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => DocumentMut::new(),
Err(e) => return Err(e.into()),
};
// Activate custom theme and persist label
doc["tui"]["theme"]["name"] = toml_edit::value("custom");
doc["tui"]["theme"]["label"] = toml_edit::value(label);
// Ensure colors table exists and write provided keys
{
use toml_edit::Item as It;
if !doc["tui"]["theme"].is_table() {
doc["tui"]["theme"] = It::Table(toml_edit::Table::new());
}
let theme_tbl = doc["tui"]["theme"].as_table_mut().unwrap();
if !theme_tbl.contains_key("colors") {
theme_tbl.insert("colors", It::Table(toml_edit::Table::new()));
}
let colors_tbl = theme_tbl["colors"].as_table_mut().unwrap();
macro_rules! set_opt {
($key:ident) => {
if let Some(ref v) = colors.$key { colors_tbl.insert(stringify!($key), toml_edit::value(v.clone())); }
};
}
set_opt!(primary);
set_opt!(secondary);
set_opt!(background);
set_opt!(foreground);
set_opt!(border);
set_opt!(border_focused);
set_opt!(selection);
set_opt!(cursor);
set_opt!(success);
set_opt!(warning);
set_opt!(error);
set_opt!(info);
set_opt!(text);
set_opt!(text_dim);
set_opt!(text_bright);
set_opt!(keyword);
set_opt!(string);
set_opt!(comment);
set_opt!(function);
set_opt!(spinner);
set_opt!(progress);
}
std::fs::create_dir_all(codex_home)?;
let tmp = NamedTempFile::new_in(codex_home)?;
std::fs::write(tmp.path(), doc.to_string())?;
tmp.persist(config_path)?;
Ok(())
}
/// Persist the GitHub workflow check preference under `[github].check_workflows_on_push`.
pub fn set_github_check_on_push(codex_home: &Path, enabled: bool) -> anyhow::Result<()> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
+7
View File
@@ -210,6 +210,12 @@ pub struct ThemeConfig {
/// Custom color overrides (optional)
#[serde(default)]
pub colors: ThemeColors,
/// Optional display name when using a custom theme generated by the user.
/// Not used for built-in themes. If `name == Custom` and this is set, the
/// UI may display it in place of the generic "Custom" label.
#[serde(default)]
pub label: Option<String>,
}
impl Default for ThemeConfig {
@@ -217,6 +223,7 @@ impl Default for ThemeConfig {
Self {
name: ThemeName::default(),
colors: ThemeColors::default(),
label: None,
}
}
}
@@ -180,11 +180,13 @@ impl ThemeSelectionView {
fn move_selection_down(&mut self) {
if matches!(self.mode, Mode::Themes) {
let options = Self::get_theme_options();
if self.selected_theme_index + 1 < options.len() {
// Allow moving onto the extra pseudo-row
if self.selected_theme_index + 1 <= options.len() {
self.selected_theme_index += 1;
self.current_theme = options[self.selected_theme_index].0;
self.app_event_tx
.send(AppEvent::PreviewTheme(self.current_theme));
if self.selected_theme_index < options.len() {
self.current_theme = options[self.selected_theme_index].0;
self.app_event_tx.send(AppEvent::PreviewTheme(self.current_theme));
}
}
} else {
let names = crate::spinner::spinner_names();
@@ -236,6 +238,7 @@ impl ThemeSelectionView {
}
Mode::Overview => {}
Mode::CreateSpinner(_) => {}
Mode::CreateTheme(_) => {}
}
self.mode = Mode::Overview;
}
@@ -417,6 +420,166 @@ impl ThemeSelectionView {
});
});
}
/// Spawn a background task that creates a custom theme using the LLM.
fn kickoff_theme_creation(
&self,
user_prompt: String,
progress_tx: std::sync::mpsc::Sender<ProgressMsg>,
) {
let tx = self.app_event_tx.clone();
// Capture a compact example of the current theme as guidance
fn color_to_hex(c: ratatui::style::Color) -> Option<String> {
match c {
ratatui::style::Color::Rgb(r, g, b) => Some(format!("#{:02X}{:02X}{:02X}", r, g, b)),
_ => None,
}
}
let cur = crate::theme::current_theme();
let mut example = serde_json::json!({"name": "Current", "colors": {}});
if let Some(v) = color_to_hex(cur.primary) { example["colors"]["primary"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.secondary) { example["colors"]["secondary"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.background) { example["colors"]["background"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.foreground) { example["colors"]["foreground"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.border) { example["colors"]["border"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.border_focused) { example["colors"]["border_focused"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.selection) { example["colors"]["selection"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.cursor) { example["colors"]["cursor"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.success) { example["colors"]["success"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.warning) { example["colors"]["warning"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.error) { example["colors"]["error"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.info) { example["colors"]["info"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.text) { example["colors"]["text"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.text_dim) { example["colors"]["text_dim"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.text_bright) { example["colors"]["text_bright"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.keyword) { example["colors"]["keyword"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.string) { example["colors"]["string"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.comment) { example["colors"]["comment"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.function) { example["colors"]["function"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.spinner) { example["colors"]["spinner"] = serde_json::Value::String(v); }
if let Some(v) = color_to_hex(cur.progress) { example["colors"]["progress"] = serde_json::Value::String(v); }
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
Ok(rt) => rt,
Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Failed to start runtime: {}", e))); return; }
};
let _ = rt.block_on(async move {
let cfg = match codex_core::config::Config::load_with_cli_overrides(vec![], codex_core::config::ConfigOverrides::default()) {
Ok(c) => c,
Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Config error: {}", e))); return; }
};
let auth_mgr = codex_core::AuthManager::shared(
cfg.codex_home.clone(),
codex_protocol::mcp_protocol::AuthMode::ApiKey,
cfg.responses_originator_header.clone(),
);
let client = codex_core::ModelClient::new(
std::sync::Arc::new(cfg.clone()),
Some(auth_mgr),
cfg.model_provider.clone(),
cfg.model_reasoning_effort,
cfg.model_reasoning_summary,
cfg.model_text_verbosity,
uuid::Uuid::new_v4(),
std::sync::Arc::new(std::sync::Mutex::new(codex_core::debug_logger::DebugLogger::new(false).unwrap_or_else(|_| codex_core::debug_logger::DebugLogger::new(false).expect("debug logger")))),
);
// Prompt with example
let developer = format!(
"You are designing a TUI color theme.\n\nRequirements:\n- Output JSON ONLY, no prose.\n- Provide a human-friendly `name`.\n- Provide a `colors` object with hex color strings (e.g., #RRGGBB) for any of these keys: primary, secondary, background, foreground, border, border_focused, selection, cursor, success, warning, error, info, text, text_dim, text_bright, keyword, string, comment, function, spinner, progress.\n- Omit keys you don't wish to override.\n- Ensure good contrast and accessibility.\n- Prefer cohesive palettes.\n\nCurrent theme example (for reference, you may adjust it):\n{}",
example.to_string()
);
let mut input: Vec<codex_protocol::models::ResponseItem> = Vec::new();
input.push(codex_protocol::models::ResponseItem::Message { id: None, role: "developer".to_string(), content: vec![codex_protocol::models::ContentItem::InputText { text: developer }] });
input.push(codex_protocol::models::ResponseItem::Message { id: None, role: "user".to_string(), content: vec![codex_protocol::models::ContentItem::InputText { text: user_prompt }] });
let schema = serde_json::json!({
"type": "object",
"properties": {
"name": {"type": "string", "minLength": 1, "maxLength": 40},
"colors": {
"type": "object",
"additionalProperties": false,
"properties": {
"primary": {"type": "string"},
"secondary": {"type": "string"},
"background": {"type": "string"},
"foreground": {"type": "string"},
"border": {"type": "string"},
"border_focused": {"type": "string"},
"selection": {"type": "string"},
"cursor": {"type": "string"},
"success": {"type": "string"},
"warning": {"type": "string"},
"error": {"type": "string"},
"info": {"type": "string"},
"text": {"type": "string"},
"text_dim": {"type": "string"},
"text_bright": {"type": "string"},
"keyword": {"type": "string"},
"string": {"type": "string"},
"comment": {"type": "string"},
"function": {"type": "string"},
"spinner": {"type": "string"},
"progress": {"type": "string"}
}
}
},
"required": ["name", "colors"],
"additionalProperties": false
});
let format = codex_core::TextFormat { r#type: "json_schema".to_string(), name: Some("custom_theme".to_string()), strict: Some(true), schema: Some(schema) };
let mut prompt = codex_core::Prompt::default();
prompt.input = input;
prompt.store = true;
prompt.text_format = Some(format);
use futures::StreamExt;
let _ = progress_tx.send(ProgressMsg::ThinkingDelta("(connecting to model)".to_string()));
let mut stream = match client.stream(&prompt).await { Ok(s) => s, Err(e) => { tx.send(AppEvent::InsertBackgroundEventEarly(format!("Request error: {}", e))); return; } };
let mut out = String::new();
while let Some(ev) = stream.next().await {
match ev {
Ok(codex_core::ResponseEvent::OutputTextDelta { delta, .. }) => { let _ = progress_tx.send(ProgressMsg::OutputDelta(delta.clone())); out.push_str(&delta); }
Ok(codex_core::ResponseEvent::ReasoningSummaryDelta { delta, .. }) => { let _ = progress_tx.send(ProgressMsg::ThinkingDelta(delta)); }
Ok(codex_core::ResponseEvent::Completed { .. }) => break,
_ => {}
}
}
let v: serde_json::Value = match serde_json::from_str(&out) { Ok(v) => v, Err(_) => { let _ = progress_tx.send(ProgressMsg::CompletedErr { error: "invalid json".to_string(), _raw_snippet: out.chars().take(200).collect() }); return; } };
let name = v.get("name").and_then(|x| x.as_str()).unwrap_or("Custom").trim().to_string();
let mut colors = codex_core::config_types::ThemeColors::default();
if let Some(map) = v.get("colors").and_then(|x| x.as_object()) {
let get = |k: &str| map.get(k).and_then(|x| x.as_str()).map(|s| s.trim().to_string());
colors.primary = get("primary");
colors.secondary = get("secondary");
colors.background = get("background");
colors.foreground = get("foreground");
colors.border = get("border");
colors.border_focused = get("border_focused");
colors.selection = get("selection");
colors.cursor = get("cursor");
colors.success = get("success");
colors.warning = get("warning");
colors.error = get("error");
colors.info = get("info");
colors.text = get("text");
colors.text_dim = get("text_dim");
colors.text_bright = get("text_bright");
colors.keyword = get("keyword");
colors.string = get("string");
colors.comment = get("comment");
colors.function = get("function");
colors.spinner = get("spinner");
colors.progress = get("progress");
}
let _ = progress_tx.send(ProgressMsg::CompletedThemeOk(name, colors));
});
});
}
}
enum Mode {
@@ -424,6 +587,7 @@ enum Mode {
Themes,
Spinner,
CreateSpinner(CreateState),
CreateTheme(CreateThemeState),
}
struct CreateState {
@@ -445,6 +609,18 @@ struct CreateState {
proposed_name: std::cell::RefCell<Option<String>>,
}
struct CreateThemeState {
step: std::cell::Cell<CreateStep>,
prompt: String,
is_loading: std::cell::Cell<bool>,
action_idx: usize, // 0 = Create/Save, 1 = Cancel/Retry
rx: Option<std::sync::mpsc::Receiver<ProgressMsg>>,
thinking_lines: std::cell::RefCell<Vec<String>>,
thinking_current: std::cell::RefCell<String>,
proposed_name: std::cell::RefCell<Option<String>>,
proposed_colors: std::cell::RefCell<Option<codex_core::config_types::ThemeColors>>,
}
#[derive(Copy, Clone, PartialEq)]
enum CreateStep {
Prompt,
@@ -461,6 +637,7 @@ enum ProgressMsg {
interval: u64,
frames: Vec<String>,
},
CompletedThemeOk(String, codex_core::config_types::ThemeColors),
// `_raw_snippet` is captured for potential future display/debugging
CompletedErr {
error: String,
@@ -482,6 +659,16 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
s.prompt.push_str(&paste);
return ConditionalUpdate::NeedsRedraw;
}
} else if let Mode::CreateTheme(ref mut s) = self.mode {
if s.is_loading.get() {
return ConditionalUpdate::NoRedraw;
}
if matches!(s.step.get(), CreateStep::Prompt) {
let paste = text.replace('\r', "\n");
let paste = paste.replace('\n', " ");
s.prompt.push_str(&paste);
return ConditionalUpdate::NeedsRedraw;
}
}
ConditionalUpdate::NoRedraw
}
@@ -491,7 +678,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
Mode::Overview => 6,
// Detail lists: fixed 9 visible rows (max), shrink if fewer
Mode::Themes => {
let n = Self::get_theme_options().len() as u16;
let n = (Self::get_theme_options().len() as u16) + 1; // +1 for "Create your own…"
// Border(2) + padding(2) + title(1)+space(1) + list
6 + n.min(9)
}
@@ -504,6 +691,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
// Title + spacer + 2 fields + buttons + help = 6 content rows
// plus border(2) + padding(2) = 10; add 2 rows headroom for small terminals
Mode::CreateSpinner(_) => 12,
Mode::CreateTheme(_) => 12,
}
}
@@ -532,6 +720,13 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
};
s.step.set(new_step);
} else if let Mode::CreateTheme(ref mut s) = self.mode {
let new_step = match s.step.get() {
CreateStep::Prompt => CreateStep::Action,
CreateStep::Action => { if s.action_idx > 0 { s.action_idx -= 1; } CreateStep::Action },
CreateStep::Review => { if s.action_idx > 0 { s.action_idx -= 1; } CreateStep::Review },
};
s.step.set(new_step);
} else {
match self.mode {
Mode::Overview => {
@@ -565,6 +760,13 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
};
s.step.set(new_step);
} else if let Mode::CreateTheme(ref mut s) = self.mode {
let new_step = match s.step.get() {
CreateStep::Prompt => CreateStep::Action,
CreateStep::Action => { if s.action_idx < 1 { s.action_idx += 1; } CreateStep::Action },
CreateStep::Review => { if s.action_idx < 1 { s.action_idx += 1; } CreateStep::Review },
};
s.step.set(new_step);
} else {
match &self.mode {
Mode::Overview => {
@@ -607,8 +809,24 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
}
}
Mode::Themes => {
// confirm_theme sets self.mode back to Overview
self.confirm_theme()
// If tail row selected (Create your own…), open create form
let count = Self::get_theme_options().len();
if self.selected_theme_index >= count {
self.mode = Mode::CreateTheme(CreateThemeState {
step: std::cell::Cell::new(CreateStep::Prompt),
prompt: String::new(),
is_loading: std::cell::Cell::new(false),
action_idx: 0,
rx: None,
thinking_lines: std::cell::RefCell::new(Vec::new()),
thinking_current: std::cell::RefCell::new(String::new()),
proposed_name: std::cell::RefCell::new(None),
proposed_colors: std::cell::RefCell::new(None),
});
} else {
// confirm_theme sets self.mode back to Overview
self.confirm_theme()
}
}
Mode::Spinner => {
// If tail row selected (Create your own…), open create form
@@ -722,6 +940,78 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
self.mode = Mode::CreateSpinner(s);
}
}
Mode::CreateTheme(mut s) => {
let mut go_overview = false;
match s.step.get() {
CreateStep::Prompt => {
if !s.is_loading.get() {
let user_prompt = s.prompt.clone();
s.is_loading.set(true);
s.thinking_lines.borrow_mut().clear();
s.thinking_current.borrow_mut().clear();
s.proposed_name.replace(None);
s.proposed_colors.replace(None);
let (txp, rxp) = std::sync::mpsc::channel::<ProgressMsg>();
s.rx = Some(rxp);
self.kickoff_theme_creation(user_prompt, txp);
self.app_event_tx.send(AppEvent::RequestRedraw);
}
}
CreateStep::Action => {
if s.action_idx == 0 && !s.is_loading.get() {
let user_prompt = s.prompt.clone();
s.is_loading.set(true);
s.thinking_lines.borrow_mut().clear();
s.thinking_current.borrow_mut().clear();
s.proposed_name.replace(None);
s.proposed_colors.replace(None);
let (txp, rxp) = std::sync::mpsc::channel::<ProgressMsg>();
s.rx = Some(rxp);
self.kickoff_theme_creation(user_prompt, txp);
self.app_event_tx.send(AppEvent::RequestRedraw);
} else {
// Cancel
go_overview = true;
}
}
CreateStep::Review => {
if s.action_idx == 0 {
// Save
if let (Some(name), Some(colors)) = (
s.proposed_name.borrow().clone(),
s.proposed_colors.borrow().clone(),
) {
if let Ok(home) = codex_core::config::find_codex_home() {
let _ = codex_core::config::set_custom_theme(&home, &name, &colors);
}
// Apply and record
crate::theme::set_custom_theme_label(name.clone());
crate::theme::init_theme(&codex_core::config_types::ThemeConfig { name: ThemeName::Custom, colors: colors.clone(), label: Some(name.clone()) });
self.revert_theme_on_back = ThemeName::Custom;
self.current_theme = ThemeName::Custom;
self.app_event_tx.send(AppEvent::UpdateTheme(ThemeName::Custom));
self.app_event_tx.send(AppEvent::InsertBackgroundEventEarly("Custom theme saved".to_string()));
go_overview = true;
}
} else {
// Retry -> back to input
s.thinking_lines.borrow_mut().clear();
s.thinking_current.borrow_mut().clear();
s.proposed_name.replace(None);
s.proposed_colors.replace(None);
s.step.set(CreateStep::Prompt);
self.app_event_tx.send(AppEvent::RequestRedraw);
// Revert to previous theme while editing
self.app_event_tx.send(AppEvent::PreviewTheme(self.revert_theme_on_back));
}
}
}
if go_overview {
self.mode = Mode::Overview;
} else {
self.mode = Mode::CreateTheme(s);
}
}
}
}
KeyEvent {
@@ -733,6 +1023,11 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
Mode::CreateSpinner(_) => {
self.mode = Mode::Spinner;
}
Mode::CreateTheme(_) => {
// Revert preview to prior theme
self.app_event_tx.send(AppEvent::PreviewTheme(self.revert_theme_on_back));
self.mode = Mode::Themes;
}
_ => self.cancel_detail(),
},
KeyEvent {
@@ -853,40 +1148,37 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
let mut lines = Vec::new();
if matches!(self.mode, Mode::Overview) {
// Overview: two clear actions, also show current values
let theme_label = Self::get_theme_options()
.iter()
.find(|(t, _, _)| *t == self.current_theme)
.map(|(_, name, _)| *name)
.unwrap_or("Theme");
let theme_label_owned = if self.current_theme == ThemeName::Custom {
crate::theme::custom_theme_label().unwrap_or_else(|| "Custom".to_string())
} else {
Self::get_theme_options()
.iter()
.find(|(t, _, _)| *t == self.current_theme)
.map(|(_, name, _)| (*name).to_string())
.unwrap_or_else(|| "Theme".to_string())
};
// Row 0: Change theme
for (i, k, v, is_spinner) in [
(0usize, "Change theme", theme_label, false),
(1usize, "Change spinner", "", true),
] {
let selected = i == self.overview_selected_index;
// Row 0: Theme
{
let selected = 0 == self.overview_selected_index;
let mut spans = vec![Span::raw(" ")];
if selected {
spans.push(Span::styled("› ", Style::default().fg(theme.keyword)));
} else {
spans.push(Span::raw(" "));
}
if selected {
spans.push(Span::styled(
k,
Style::default()
.fg(theme.primary)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::styled(k, Style::default().fg(theme.text)));
}
if selected { spans.push(Span::styled("› ", Style::default().fg(theme.keyword))); } else { spans.push(Span::raw(" ")); }
let k = "Change theme";
if selected { spans.push(Span::styled(k, Style::default().fg(theme.primary).add_modifier(Modifier::BOLD))); } else { spans.push(Span::styled(k, Style::default().fg(theme.text))); }
spans.push(Span::raw(" — "));
if is_spinner {
let label = crate::spinner::spinner_label_for(&self.current_spinner);
spans.push(Span::styled(label, Style::default().fg(theme.text_dim)));
} else {
spans.push(Span::styled(v, Style::default().fg(theme.text_dim)));
}
spans.push(Span::styled(theme_label_owned, Style::default().fg(theme.text_dim)));
lines.push(Line::from(spans));
}
// Row 1: Spinner
{
let selected = 1 == self.overview_selected_index;
let mut spans = vec![Span::raw(" ")];
if selected { spans.push(Span::styled("› ", Style::default().fg(theme.keyword))); } else { spans.push(Span::raw(" ")); }
let k = "Change spinner";
if selected { spans.push(Span::styled(k, Style::default().fg(theme.primary).add_modifier(Modifier::BOLD))); } else { spans.push(Span::styled(k, Style::default().fg(theme.text))); }
spans.push(Span::raw(" — "));
let label = crate::spinner::spinner_label_for(&self.current_spinner);
spans.push(Span::styled(label, Style::default().fg(theme.text_dim)));
lines.push(Line::from(spans));
}
} else if matches!(self.mode, Mode::Themes) {
@@ -907,8 +1199,25 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
);
let end = (start + visible).min(count + 1); // +1 for "Create your own…"
for i in start..end {
let (theme_enum, name, description) = &options[i];
let is_selected = i == self.selected_theme_index;
if i >= count {
// Pseudo-row: Create your own…
let mut spans = vec![Span::raw(" ")];
if is_selected {
spans.push(Span::styled("› ", Style::default().fg(theme.keyword)));
} else {
spans.push(Span::raw(" "));
}
let label_style = if is_selected {
Style::default().fg(theme.primary).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.text_dim)
};
spans.push(Span::styled("Create your own…", label_style));
lines.push(Line::from(spans));
continue;
}
let (theme_enum, name, description) = &options[i];
let is_original = *theme_enum == self.original_theme;
let prefix_selected = is_selected;
@@ -1028,6 +1337,7 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Ok(ProgressMsg::CompletedThemeOk(_, _)) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
}
@@ -1224,6 +1534,107 @@ impl<'a> BottomPaneView<'a> for ThemeSelectionView {
.render(body_area, buf);
}
return;
} else if matches!(self.mode, Mode::CreateTheme(_)) {
let theme = crate::theme::current_theme();
if let Mode::CreateTheme(s) = &self.mode {
if let Some(rx) = &s.rx {
for _ in 0..100 {
match rx.try_recv() {
Ok(ProgressMsg::ThinkingDelta(d)) | Ok(ProgressMsg::OutputDelta(d)) => {
if let Mode::CreateTheme(ref sm) = self.mode {
let mut cur = sm.thinking_current.borrow_mut();
let mut hist = sm.thinking_lines.borrow_mut();
cur.push_str(&d);
if let Some(pos) = cur.rfind('\n') {
let (complete, remainder) = cur.split_at(pos);
if !complete.trim().is_empty() { hist.push(complete.trim().to_string()); }
*cur = remainder.trim_start_matches('\n').to_string();
let keep = 10usize; let len = hist.len(); if len > keep { hist.drain(0..len - keep); }
}
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::CompletedThemeOk(name, colors)) => {
if let Mode::CreateTheme(ref sm) = self.mode {
sm.is_loading.set(false);
sm.step.set(CreateStep::Review);
sm.proposed_name.replace(Some(name.clone()));
sm.proposed_colors.replace(Some(colors.clone()));
crate::theme::set_custom_theme_label(name.clone());
crate::theme::init_theme(&codex_core::config_types::ThemeConfig { name: ThemeName::Custom, colors: colors.clone(), label: Some(name) });
}
self.app_event_tx.send(AppEvent::RequestRedraw);
}
Ok(ProgressMsg::CompletedOk { .. }) => {}
Ok(ProgressMsg::RawOutput(_)) => {}
Ok(ProgressMsg::CompletedErr { .. }) => { self.app_event_tx.send(AppEvent::RequestRedraw); }
Err(std::sync::mpsc::TryRecvError::Empty) => break,
Err(std::sync::mpsc::TryRecvError::Disconnected) => break,
}
}
}
let mut form_lines = Vec::new();
if s.is_loading.get() {
form_lines.push(Line::from(Span::styled(
"Overview » Change Theme » Create Custom",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
form_lines.push(Line::default());
use std::time::{SystemTime, UNIX_EPOCH};
let now_ms = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_millis() as u64;
let frames = ["◌", "◔", "◑", "◕", "●", "◕", "◑", "◔"]; let frame = frames[((now_ms / 100) as usize) % frames.len()].to_string();
form_lines.push(Line::from(vec![Span::styled(frame, Style::default().fg(crate::colors::info())), Span::raw(" Generating theme with AI…")]));
let cur = s.thinking_current.borrow();
let latest = if !cur.trim().is_empty() { cur.trim().to_string() } else { s.thinking_lines.borrow().iter().rev().find(|l| !l.trim().is_empty()).cloned().unwrap_or_else(|| "Waiting for model…".to_string()) };
let mut latest_render = latest.to_string(); if !latest_render.ends_with('…') { latest_render.push_str(" …"); }
form_lines.push(Line::from(Span::styled(latest_render, Style::default().fg(theme.text_dim))));
self.app_event_tx.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(100)));
Paragraph::new(form_lines).alignment(Alignment::Left).wrap(ratatui::widgets::Wrap { trim: false }).render(body_area, buf);
return;
}
if matches!(s.step.get(), CreateStep::Review) {
form_lines.push(Line::from(Span::styled(
"Overview » Change Theme » Create Custom",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
form_lines.push(Line::default());
let primary_selected = s.action_idx == 0; let secondary_selected = s.action_idx == 1;
let sel = |b: bool| if b { Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text) };
let mut spans: Vec<Span> = Vec::new();
spans.push(Span::styled("[ Save ]", sel(primary_selected)));
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Retry ]", sel(secondary_selected)));
form_lines.push(Line::from(spans));
Paragraph::new(form_lines).alignment(Alignment::Left).wrap(ratatui::widgets::Wrap { trim: false }).render(body_area, buf);
return;
}
// Idle form
form_lines.push(Line::from(Span::styled(
"Overview » Change Theme » Create Custom",
Style::default().fg(theme.text_bright).add_modifier(Modifier::BOLD),
)));
form_lines.push(Line::default());
form_lines.push(Line::from(Span::styled("Code can generate a custom theme just for you!", Style::default().fg(theme.text))));
form_lines.push(Line::from(Span::styled("What should it look like? (e.g. OLED dark with cyan + violet accents)", Style::default().fg(theme.text_dim))));
form_lines.push(Line::default());
let mut desc_spans: Vec<Span> = Vec::new();
desc_spans.push(Span::styled("Description: ", Style::default().fg(theme.keyword)));
let active = matches!(s.step.get(), CreateStep::Prompt);
desc_spans.push(Span::raw(s.prompt.clone()));
if active { desc_spans.push(Span::styled("▏", Style::default().fg(theme.info))); }
form_lines.push(Line::from(desc_spans));
form_lines.push(Line::from(Span::styled("─".repeat((body_area.width.saturating_sub(4)) as usize), Style::default().fg(crate::colors::border()))));
let mut spans: Vec<Span> = Vec::new();
let on_actions = matches!(s.step.get(), CreateStep::Action);
let primary_selected = on_actions && s.action_idx == 0; let secondary_selected = on_actions && s.action_idx == 1;
let sel = |b: bool| if b { Style::default().fg(theme.primary).add_modifier(Modifier::BOLD) } else { Style::default().fg(theme.text) };
spans.push(Span::styled("[ Generate... ]", sel(primary_selected)));
spans.push(Span::raw(" "));
spans.push(Span::styled("[ Cancel ]", sel(secondary_selected)));
form_lines.push(Line::from(spans));
Paragraph::new(form_lines).alignment(Alignment::Left).wrap(ratatui::widgets::Wrap { trim: false }).render(body_area, buf);
return;
}
} else {
// Spinner: render one centered preview row per spinner, matching the composer title
use std::time::SystemTime;
+15
View File
@@ -7,6 +7,7 @@ use std::sync::RwLock;
lazy_static! {
static ref CURRENT_THEME: RwLock<Theme> = RwLock::new(Theme::default());
static ref CUSTOM_THEME_LABEL: RwLock<Option<String>> = RwLock::new(None);
}
/// Represents a complete theme with all colors resolved
@@ -66,6 +67,10 @@ pub fn init_theme(config: &ThemeConfig) {
let mut current = CURRENT_THEME.write().unwrap();
*current = theme;
// Track custom theme label for UI display
if matches!(config.name, ThemeName::Custom) {
*CUSTOM_THEME_LABEL.write().unwrap() = config.label.clone();
}
}
/// Get the current theme
@@ -73,6 +78,16 @@ pub fn current_theme() -> Theme {
CURRENT_THEME.read().unwrap().clone()
}
/// Get the custom theme's display label, if any
pub fn custom_theme_label() -> Option<String> {
CUSTOM_THEME_LABEL.read().unwrap().clone()
}
/// Set/update the custom theme's label at runtime
pub fn set_custom_theme_label(label: String) {
*CUSTOM_THEME_LABEL.write().unwrap() = Some(label);
}
/// Switch to a different predefined theme
pub fn switch_theme(theme_name: ThemeName) {
let theme = get_predefined_theme(theme_name);