diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index b9986e65f415..0268d722e48b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -530,6 +530,15 @@ }, "type": "object" }, + "DiffBackgroundMode": { + "enum": [ + "auto", + "off", + "theme", + "custom" + ], + "type": "string" + }, "FeedbackConfigToml": { "additionalProperties": false, "properties": { @@ -1396,6 +1405,25 @@ "description": "Enable animations (welcome screen, shimmer effects, spinners). Defaults to `true`.", "type": "boolean" }, + "diff_add_bg": { + "default": null, + "description": "Custom insert-line background color (`#RRGGBB`).\n\nUsed when `diff_background = \"custom\"`.", + "type": "string" + }, + "diff_background": { + "allOf": [ + { + "$ref": "#/definitions/DiffBackgroundMode" + } + ], + "default": "auto", + "description": "Controls how diff add/remove backgrounds are rendered in the TUI.\n\n- `auto` (default): Use built-in adaptive add/remove backgrounds. - `off`: Disable add/remove line backgrounds. - `theme`: Derive add/remove backgrounds from the active syntax theme (`markup.inserted`/`markup.deleted` with diff fallbacks). - `custom`: Use user-provided custom colors from `diff_add_bg` / `diff_del_bg`." + }, + "diff_del_bg": { + "default": null, + "description": "Custom delete-line background color (`#RRGGBB`).\n\nUsed when `diff_background = \"custom\"`.", + "type": "string" + }, "notification_method": { "allOf": [ { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index a7139eac5775..372913471a90 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -3,6 +3,7 @@ use crate::config::edit::ConfigEdit; use crate::config::edit::ConfigEditsBuilder; use crate::config::types::AppsConfigToml; use crate::config::types::DEFAULT_OTEL_ENVIRONMENT; +use crate::config::types::DiffBackgroundMode; use crate::config::types::History; use crate::config::types::McpServerConfig; use crate::config::types::McpServerDisabledReason; @@ -298,6 +299,15 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Controls how diff add/remove backgrounds are rendered in the TUI. + pub tui_diff_background: DiffBackgroundMode, + + /// Custom insert-line background color (`#RRGGBB`). + pub tui_diff_add_bg: Option, + + /// Custom delete-line background color (`#RRGGBB`). + pub tui_diff_del_bg: Option, + /// The directory that should be treated as the current working directory /// for the session. All relative paths inside the business-logic layer are /// resolved against this path. @@ -2197,6 +2207,13 @@ impl Config { .unwrap_or_default(), tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + tui_diff_background: cfg + .tui + .as_ref() + .map(|t| t.diff_background) + .unwrap_or_default(), + tui_diff_add_bg: cfg.tui.as_ref().and_then(|t| t.diff_add_bg.clone()), + tui_diff_del_bg: cfg.tui.as_ref().and_then(|t| t.diff_del_bg.clone()), otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); @@ -2622,6 +2639,22 @@ theme = "dracula" assert_eq!(parsed.tui.as_ref().and_then(|t| t.theme.as_deref()), None); } + #[test] + fn tui_diff_background_deserializes_from_toml() { + let cfg = r##" +[tui] +diff_background = "custom" +diff_add_bg = "#213A2B" +diff_del_bg = "#4A221D" +"##; + let parsed = + toml::from_str::(cfg).expect("TOML deserialization should succeed"); + let tui = parsed.tui.expect("config should include tui section"); + assert_eq!(tui.diff_background, DiffBackgroundMode::Custom); + assert_eq!(tui.diff_add_bg.as_deref(), Some("#213A2B")); + assert_eq!(tui.diff_del_bg.as_deref(), Some("#4A221D")); + } + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2639,9 +2672,42 @@ theme = "dracula" notification_method: NotificationMethod::Auto, animations: true, show_tooltips: true, + show_compact_summary: true, + alternate_screen: AltScreenMode::Auto, + status_line: None, + theme: None, + diff_background: DiffBackgroundMode::Auto, + diff_add_bg: None, + diff_del_bg: None, + } + ); + } + + #[test] + fn tui_config_can_disable_compact_summary() { + let cfg = r#" +[tui] +show_compact_summary = false +"#; + + let parsed = toml::from_str::(cfg) + .expect("TUI config with show_compact_summary should succeed"); + let tui = parsed.tui.expect("config should include tui section"); + + assert_eq!( + tui, + Tui { + notifications: Notifications::Enabled(true), + notification_method: NotificationMethod::Auto, + animations: true, + show_tooltips: true, + show_compact_summary: false, alternate_screen: AltScreenMode::Auto, status_line: None, theme: None, + diff_background: DiffBackgroundMode::Auto, + diff_add_bg: None, + diff_del_bg: None, } ); } @@ -4799,6 +4865,9 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_diff_background: DiffBackgroundMode::Auto, + tui_diff_add_bg: None, + tui_diff_del_bg: None, otel: OtelConfig::default(), }, o3_profile_config @@ -4925,6 +4994,9 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_diff_background: DiffBackgroundMode::Auto, + tui_diff_add_bg: None, + tui_diff_del_bg: None, otel: OtelConfig::default(), }; @@ -5049,6 +5121,9 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_diff_background: DiffBackgroundMode::Auto, + tui_diff_add_bg: None, + tui_diff_del_bg: None, otel: OtelConfig::default(), }; @@ -5159,6 +5234,9 @@ model_verbosity = "high" tui_alternate_screen: AltScreenMode::Auto, tui_status_line: None, tui_theme: None, + tui_diff_background: DiffBackgroundMode::Auto, + tui_diff_add_bg: None, + tui_diff_del_bg: None, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/types.rs b/codex-rs/core/src/config/types.rs index d7026d6c22b5..974b1e9e2b24 100644 --- a/codex-rs/core/src/config/types.rs +++ b/codex-rs/core/src/config/types.rs @@ -697,6 +697,39 @@ pub struct Tui { /// Use `/theme` in the TUI or see `$CODEX_HOME/themes` for custom themes. #[serde(default)] pub theme: Option, + + /// Controls how diff add/remove backgrounds are rendered in the TUI. + /// + /// - `auto` (default): Use built-in adaptive add/remove backgrounds. + /// - `off`: Disable add/remove line backgrounds. + /// - `theme`: Derive add/remove backgrounds from the active syntax theme + /// (`markup.inserted`/`markup.deleted` with diff fallbacks). + /// - `custom`: Use user-provided custom colors from `diff_add_bg` / + /// `diff_del_bg`. + #[serde(default)] + pub diff_background: DiffBackgroundMode, + + /// Custom insert-line background color (`#RRGGBB`). + /// + /// Used when `diff_background = "custom"`. + #[serde(default)] + pub diff_add_bg: Option, + + /// Custom delete-line background color (`#RRGGBB`). + /// + /// Used when `diff_background = "custom"`. + #[serde(default)] + pub diff_del_bg: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum DiffBackgroundMode { + #[default] + Auto, + Off, + Theme, + Custom, } const fn default_true() -> bool { diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index 32591945fa77..b7c7ddf183bf 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -73,17 +73,23 @@ use crate::exec_command::relativize_to_home; use crate::render::Insets; use crate::render::highlight::exceeds_highlight_limits; use crate::render::highlight::highlight_code_to_styled_spans; +use crate::render::highlight::scope_background_rgb; use crate::render::line_utils::prefix_lines; use crate::render::renderable::ColumnRenderable; use crate::render::renderable::InsetRenderable; use crate::render::renderable::Renderable; use crate::terminal_palette::StdoutColorLevel; +use crate::terminal_palette::best_color; use crate::terminal_palette::default_bg; use crate::terminal_palette::indexed_color; use crate::terminal_palette::rgb_color; use crate::terminal_palette::stdout_color_level; +use codex_core::config::Config; +use codex_core::config::types::DiffBackgroundMode; use codex_core::git_info::get_git_repo_root; use codex_protocol::protocol::FileChange; +use std::sync::OnceLock; +use std::sync::RwLock; /// Classifies a diff line for gutter sign rendering and style selection. /// @@ -117,6 +123,61 @@ enum DiffColorLevel { Ansi16, } +#[derive(Clone, Debug)] +struct DiffBackgroundSettings { + mode: DiffBackgroundMode, + add_bg: Option<(u8, u8, u8)>, + del_bg: Option<(u8, u8, u8)>, +} + +impl Default for DiffBackgroundSettings { + fn default() -> Self { + Self { + mode: DiffBackgroundMode::Auto, + add_bg: None, + del_bg: None, + } + } +} + +impl DiffBackgroundSettings { + fn from_config(config: &Config) -> Self { + Self { + mode: config.tui_diff_background, + add_bg: parse_hex_rgb(config.tui_diff_add_bg.as_deref()), + del_bg: parse_hex_rgb(config.tui_diff_del_bg.as_deref()), + } + } +} + +#[derive(Clone, Copy)] +struct LineBackgrounds { + add: Option, + del: Option, +} + +static DIFF_BACKGROUND_SETTINGS: OnceLock> = OnceLock::new(); + +fn diff_background_settings_lock() -> &'static RwLock { + DIFF_BACKGROUND_SETTINGS.get_or_init(|| RwLock::new(DiffBackgroundSettings::default())) +} + +pub(crate) fn set_diff_background_settings(config: &Config) { + let parsed = DiffBackgroundSettings::from_config(config); + let mut guard = match diff_background_settings_lock().write() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + }; + *guard = parsed; +} + +fn current_diff_background_settings() -> DiffBackgroundSettings { + match diff_background_settings_lock().read() { + Ok(guard) => guard.clone(), + Err(poisoned) => poisoned.into_inner().clone(), + } +} + pub struct DiffSummary { changes: HashMap, cwd: PathBuf, @@ -884,6 +945,62 @@ fn diff_color_level() -> DiffColorLevel { } } +fn parse_hex_rgb(input: Option<&str>) -> Option<(u8, u8, u8)> { + let raw = input?.trim(); + let hex = raw.strip_prefix('#')?; + if hex.len() != 6 { + return None; + } + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some((r, g, b)) +} + +fn resolve_theme_scope_rgb(scope: &str, fallback_scope: &str) -> Option<(u8, u8, u8)> { + scope_background_rgb(scope).or_else(|| scope_background_rgb(fallback_scope)) +} + +fn quantize_rgb(rgb: (u8, u8, u8), color_level: DiffColorLevel, ansi16_fallback: Color) -> Color { + match color_level { + DiffColorLevel::TrueColor => rgb_color(rgb), + DiffColorLevel::Ansi256 => best_color(rgb), + DiffColorLevel::Ansi16 => ansi16_fallback, + } +} + +fn resolve_line_backgrounds(theme: DiffTheme, color_level: DiffColorLevel) -> LineBackgrounds { + let settings = current_diff_background_settings(); + match settings.mode { + DiffBackgroundMode::Off => LineBackgrounds { + add: None, + del: None, + }, + DiffBackgroundMode::Theme | DiffBackgroundMode::Custom => { + let (add_rgb, del_rgb) = match settings.mode { + DiffBackgroundMode::Theme => ( + resolve_theme_scope_rgb("markup.inserted", "diff.inserted"), + resolve_theme_scope_rgb("markup.deleted", "diff.deleted"), + ), + DiffBackgroundMode::Custom => (settings.add_bg, settings.del_bg), + DiffBackgroundMode::Off | DiffBackgroundMode::Auto => unreachable!(), + }; + LineBackgrounds { + add: add_rgb + .map(|rgb| quantize_rgb(rgb, color_level, Color::Green)) + .or_else(|| default_add_line_bg(theme, color_level)), + del: del_rgb + .map(|rgb| quantize_rgb(rgb, color_level, Color::Red)) + .or_else(|| default_del_line_bg(theme, color_level)), + } + } + DiffBackgroundMode::Auto => LineBackgrounds { + add: default_add_line_bg(theme, color_level), + del: default_del_line_bg(theme, color_level), + }, + } +} + // -- Style helpers ------------------------------------------------------------ // // Each diff line is composed of three visual regions, styled independently: @@ -908,9 +1025,14 @@ fn diff_color_level() -> DiffColorLevel { /// Context lines intentionally leave the background unset so the terminal /// default shows through. fn style_line_bg_for(kind: DiffLineType, theme: DiffTheme, color_level: DiffColorLevel) -> Style { + let line_backgrounds = resolve_line_backgrounds(theme, color_level); match kind { - DiffLineType::Insert => Style::default().bg(add_line_bg(theme, color_level)), - DiffLineType::Delete => Style::default().bg(del_line_bg(theme, color_level)), + DiffLineType::Insert => line_backgrounds + .add + .map_or_else(Style::default, |bg| Style::default().bg(bg)), + DiffLineType::Delete => line_backgrounds + .del + .map_or_else(Style::default, |bg| Style::default().bg(bg)), DiffLineType::Context => Style::default(), } } @@ -919,25 +1041,29 @@ fn style_context() -> Style { Style::default() } -fn add_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Color { +fn default_add_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Option { match (theme, color_level) { - (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb_color(DARK_TC_ADD_LINE_BG_RGB), - (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed_color(DARK_256_ADD_LINE_BG_IDX), - (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Green, - (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_ADD_LINE_BG_RGB), - (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed_color(LIGHT_256_ADD_LINE_BG_IDX), - (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::LightGreen, + (DiffTheme::Dark, DiffColorLevel::TrueColor) => Some(rgb_color(DARK_TC_ADD_LINE_BG_RGB)), + (DiffTheme::Dark, DiffColorLevel::Ansi256) => Some(indexed_color(DARK_256_ADD_LINE_BG_IDX)), + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Some(Color::Green), + (DiffTheme::Light, DiffColorLevel::TrueColor) => Some(rgb_color(LIGHT_TC_ADD_LINE_BG_RGB)), + (DiffTheme::Light, DiffColorLevel::Ansi256) => { + Some(indexed_color(LIGHT_256_ADD_LINE_BG_IDX)) + } + (DiffTheme::Light, DiffColorLevel::Ansi16) => Some(Color::LightGreen), } } -fn del_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Color { +fn default_del_line_bg(theme: DiffTheme, color_level: DiffColorLevel) -> Option { match (theme, color_level) { - (DiffTheme::Dark, DiffColorLevel::TrueColor) => rgb_color(DARK_TC_DEL_LINE_BG_RGB), - (DiffTheme::Dark, DiffColorLevel::Ansi256) => indexed_color(DARK_256_DEL_LINE_BG_IDX), - (DiffTheme::Dark, DiffColorLevel::Ansi16) => Color::Red, - (DiffTheme::Light, DiffColorLevel::TrueColor) => rgb_color(LIGHT_TC_DEL_LINE_BG_RGB), - (DiffTheme::Light, DiffColorLevel::Ansi256) => indexed_color(LIGHT_256_DEL_LINE_BG_IDX), - (DiffTheme::Light, DiffColorLevel::Ansi16) => Color::LightRed, + (DiffTheme::Dark, DiffColorLevel::TrueColor) => Some(rgb_color(DARK_TC_DEL_LINE_BG_RGB)), + (DiffTheme::Dark, DiffColorLevel::Ansi256) => Some(indexed_color(DARK_256_DEL_LINE_BG_IDX)), + (DiffTheme::Dark, DiffColorLevel::Ansi16) => Some(Color::Red), + (DiffTheme::Light, DiffColorLevel::TrueColor) => Some(rgb_color(LIGHT_TC_DEL_LINE_BG_RGB)), + (DiffTheme::Light, DiffColorLevel::Ansi256) => { + Some(indexed_color(LIGHT_256_DEL_LINE_BG_IDX)) + } + (DiffTheme::Light, DiffColorLevel::Ansi16) => Some(Color::LightRed), } } @@ -1000,27 +1126,27 @@ fn style_sign_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style { /// Content style for insert lines (plain, non-syntax-highlighted text). fn style_add(theme: DiffTheme, color_level: DiffColorLevel) -> Style { - match (theme, color_level) { - (DiffTheme::Dark, DiffColorLevel::Ansi16) => Style::default() - .fg(Color::Black) - .bg(add_line_bg(theme, color_level)), - (DiffTheme::Light, _) => Style::default().bg(add_line_bg(theme, color_level)), - (DiffTheme::Dark, _) => Style::default() - .fg(Color::Green) - .bg(add_line_bg(theme, color_level)), + let line_backgrounds = resolve_line_backgrounds(theme, color_level); + match (theme, color_level, line_backgrounds.add) { + (DiffTheme::Dark, DiffColorLevel::Ansi16, Some(bg)) => { + Style::default().fg(Color::Black).bg(bg) + } + (DiffTheme::Light, _, Some(bg)) => Style::default().bg(bg), + (DiffTheme::Dark, _, Some(bg)) => Style::default().fg(Color::Green).bg(bg), + (_, _, None) => Style::default().fg(Color::Green), } } /// Content style for delete lines (plain, non-syntax-highlighted text). fn style_del(theme: DiffTheme, color_level: DiffColorLevel) -> Style { - match (theme, color_level) { - (DiffTheme::Dark, DiffColorLevel::Ansi16) => Style::default() - .fg(Color::Black) - .bg(del_line_bg(theme, color_level)), - (DiffTheme::Light, _) => Style::default().bg(del_line_bg(theme, color_level)), - (DiffTheme::Dark, _) => Style::default() - .fg(Color::Red) - .bg(del_line_bg(theme, color_level)), + let line_backgrounds = resolve_line_backgrounds(theme, color_level); + match (theme, color_level, line_backgrounds.del) { + (DiffTheme::Dark, DiffColorLevel::Ansi16, Some(bg)) => { + Style::default().fg(Color::Black).bg(bg) + } + (DiffTheme::Light, _, Some(bg)) => Style::default().bg(bg), + (DiffTheme::Dark, _, Some(bg)) => Style::default().fg(Color::Red).bg(bg), + (_, _, None) => Style::default().fg(Color::Red), } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index f5d0cab601d0..882db1c61ec6 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -810,6 +810,7 @@ async fn run_ratatui_app( ) { config.startup_warnings.push(w); } + crate::diff_render::set_diff_background_settings(&config); set_default_client_residency_requirement(config.enforce_residency.value()); let active_profile = config.active_profile.clone(); diff --git a/codex-rs/tui/src/render/highlight.rs b/codex-rs/tui/src/render/highlight.rs index c1e7e56f63dc..f79a66c7af76 100644 --- a/codex-rs/tui/src/render/highlight.rs +++ b/codex-rs/tui/src/render/highlight.rs @@ -32,9 +32,11 @@ use std::sync::OnceLock; use std::sync::RwLock; use syntect::easy::HighlightLines; use syntect::highlighting::FontStyle; +use syntect::highlighting::Highlighter; use syntect::highlighting::Style as SyntectStyle; use syntect::highlighting::Theme; use syntect::highlighting::ThemeSet; +use syntect::parsing::Scope; use syntect::parsing::SyntaxReference; use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; @@ -241,6 +243,22 @@ pub(crate) fn current_syntax_theme() -> Theme { } } +/// Resolve a scope's explicit theme background color from the active syntax +/// theme. +/// +/// Returns `None` when the scope is invalid or no matching scope rule sets a +/// background color. +pub(crate) fn scope_background_rgb(scope_name: &str) -> Option<(u8, u8, u8)> { + let scope = Scope::new(scope_name).ok()?; + let theme_guard = match theme_lock().read() { + Ok(theme) => theme, + Err(poisoned) => poisoned.into_inner(), + }; + let style_mod = Highlighter::new(&theme_guard).style_mod_for_stack(&[scope]); + let background = style_mod.background?; + Some((background.r, background.g, background.b)) +} + /// Return the configured kebab-case theme name when it resolves; otherwise /// return the adaptive auto-detected default theme name. /// diff --git a/docs/config.md b/docs/config.md index 30665bb11ba2..4a097b1ddb86 100644 --- a/docs/config.md +++ b/docs/config.md @@ -34,6 +34,37 @@ Codex stores the SQLite-backed state DB under `sqlite_home` (config key) or the `CODEX_SQLITE_HOME` environment variable. When unset, WorkspaceWrite sandbox sessions default to a temp directory; other modes default to `CODEX_HOME`. +## TUI + +Hide the compacted prompt output after `/compact`: + +```toml +[tui] +show_compact_summary = false +``` + +When unset, the transcript includes the compacted prompt when available (otherwise just the summary). + +Configure diff add/remove line backgrounds: + +```toml +[tui] +# auto (default), off, theme, custom +diff_background = "theme" +``` + +- `auto`: existing built-in adaptive backgrounds. +- `off`: disable add/remove line backgrounds. +- `theme`: use syntax-theme scope backgrounds (`markup.inserted`/`markup.deleted`, then + `diff.inserted`/`diff.deleted` fallback). +- `custom`: use explicit colors below (invalid/missing values fall back to `auto` colors): + +```toml +[tui] +diff_background = "custom" +diff_add_bg = "#213A2B" +diff_del_bg = "#4A221D" +``` ## Notices Codex stores "do not show again" flags for some UI prompts under the `[notice]` table.