diff --git a/src/cli.rs b/src/cli.rs index 9764bf37..cb047c91 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -316,6 +316,10 @@ enum Commands { #[arg(long, short = 'n', conflicts_with = "session")] new: bool, + /// Apply per-window color tinting (tmux only) + #[arg(long)] + colors: bool, + /// Open in session mode (overrides stored mode for this worktree) #[arg(short = 's', long)] session: bool, @@ -722,9 +726,10 @@ pub fn run() -> Result<()> { run_hooks, force_files, new, + colors, session, prompt, - } => command::open::run(&names, run_hooks, force_files, new, session, prompt), + } => command::open::run(&names, run_hooks, force_files, new, colors, session, prompt), Commands::Close { name } => command::close::run(name.as_deref()), Commands::Merge { name, diff --git a/src/command/add.rs b/src/command/add.rs index 2c15ee60..2aa0c5d0 100644 --- a/src/command/add.rs +++ b/src/command/add.rs @@ -162,8 +162,9 @@ pub fn run( // Ensure preconditions are met (git repo and multiplexer session) check_preconditions()?; - // Extract sandbox override before consuming setup flags + // Extract overrides before consuming setup flags let sandbox_override = setup.sandbox; + let colors_override = setup.colors; // Load config early to determine mode (CLI flag overrides config) let initial_config = config::Config::load(multi.agent.first().map(|s| s.as_str()))?; @@ -288,6 +289,9 @@ pub fn run( if sandbox_override { rescue_config.sandbox.enabled = Some(true); } + if colors_override { + rescue_config.window_colors = Some(true); + } let mux = create_backend(detect_backend()); let rescue_context = workflow::WorkflowContext::new(rescue_config, mux, rescue_location)?; // Derive handle for rescue flow (uses config for naming strategy/prefix) @@ -414,6 +418,7 @@ pub fn run( deferred_auto_name, max_concurrent: multi.max_concurrent, sandbox_override, + colors_override, prompt_file_only, }; plan.execute() @@ -543,6 +548,7 @@ struct CreationPlan<'a> { deferred_auto_name: bool, max_concurrent: Option, sandbox_override: bool, + colors_override: bool, prompt_file_only: bool, } @@ -593,6 +599,9 @@ impl<'a> CreationPlan<'a> { if self.sandbox_override { config.sandbox.enabled = Some(true); } + if self.colors_override { + config.window_colors = Some(true); + } // Render prompt first (needed for deferred auto-name) let rendered_prompt = if let Some(doc) = self.prompt_doc { diff --git a/src/command/args.rs b/src/command/args.rs index b7ff5c0b..ba060c1b 100644 --- a/src/command/args.rs +++ b/src/command/args.rs @@ -58,6 +58,10 @@ pub struct SetupFlags { /// Enable sandbox mode even when disabled in config #[arg(short = 'S', long)] pub sandbox: bool, + + /// Apply per-window color tinting (tmux only) + #[arg(long)] + pub colors: bool, } #[derive(clap::Args, Debug)] diff --git a/src/command/open.rs b/src/command/open.rs index 41c6ae84..4196de0c 100644 --- a/src/command/open.rs +++ b/src/command/open.rs @@ -11,6 +11,7 @@ pub fn run( run_hooks: bool, force_files: bool, new_window: bool, + colors: bool, session: bool, prompt_args: PromptArgs, ) -> Result<()> { @@ -33,7 +34,10 @@ pub fn run( bail!("Prompt arguments (-p, -P, -e) cannot be used when opening multiple worktrees"); } - let (config, config_location) = config::Config::load_with_location(None)?; + let (mut config, config_location) = config::Config::load_with_location(None)?; + if colors { + config.window_colors = Some(true); + } let mux = create_backend(detect_backend()); let context = WorkflowContext::new(config, mux, config_location)?; diff --git a/src/config.rs b/src/config.rs index 390b1fd3..247142ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -193,6 +193,12 @@ pub struct Config { #[serde(default)] pub status_format: Option, + /// Apply subtle background color tinting per window (tmux only). + /// Colors are derived from the branch name for consistent identification. + /// Default: false + #[serde(default)] + pub window_colors: Option, + /// Custom icons for agent status display. #[serde(default)] pub status_icons: StatusIcons, @@ -1504,6 +1510,7 @@ impl Config { panes, windows, status_format, + window_colors, nerdfont, auto_update_check, prompt_file_only, @@ -1904,6 +1911,11 @@ pub const EXAMPLE_PROJECT_CONFIG: &str = r#"# workmux project configuration # Default: true # status_format: true +# Apply subtle background color tinting per window (tmux only). +# Colors are derived from the branch name for consistent identification. +# Default: false +# window_colors: true + # Custom icons for agent status display. # status_icons: # working: "🤖" diff --git a/src/multiplexer/mod.rs b/src/multiplexer/mod.rs index 1209b342..9f4b222e 100644 --- a/src/multiplexer/mod.rs +++ b/src/multiplexer/mod.rs @@ -158,6 +158,13 @@ pub trait Multiplexer: Send + Sync { /// Wait until the specified session is closed fn wait_until_session_closed(&self, full_session_name: &str) -> Result<()>; + /// Apply a background color tint to a window based on a name hash. + /// Colors are derived from the name for consistent identification. + /// Only supported by tmux backend. Other backends silently ignore this. + fn apply_window_color(&self, _pane_id: &str, _name: &str) -> Result<()> { + Ok(()) + } + // === Pane Management === /// Select (focus) a pane by ID diff --git a/src/multiplexer/tmux.rs b/src/multiplexer/tmux.rs index 52d8c8df..89f976b5 100644 --- a/src/multiplexer/tmux.rs +++ b/src/multiplexer/tmux.rs @@ -556,6 +556,30 @@ impl Multiplexer for TmuxBackend { } } + fn apply_window_color(&self, pane_id: &str, name: &str) -> Result<()> { + let scheme = window_color_for_name(name); + // Pane content: background and text color + let pane_style = format!("bg={},fg={}", scheme.bg, scheme.fg); + self.tmux_cmd(&["set-option", "-w", "-t", pane_id, "window-style", &pane_style])?; + self.tmux_cmd(&["set-option", "-w", "-t", pane_id, "window-active-style", &pane_style])?; + // Pane borders + self.tmux_cmd(&["set-option", "-w", "-t", pane_id, "pane-border-style", &format!("fg={}", scheme.border)])?; + self.tmux_cmd(&["set-option", "-w", "-t", pane_id, "pane-active-border-style", &format!("fg={}", scheme.active_border)])?; + // Status bar: this window's tab in the bottom bar + let tab_style = format!("bg={},fg={}", scheme.status_bg, scheme.status_fg); + let active_tab_style = format!("bg={},fg={},bold", scheme.active_border, scheme.status_fg); + self.tmux_cmd(&["set-option", "-w", "-t", pane_id, "window-status-style", &tab_style])?; + self.tmux_cmd(&["set-option", "-w", "-t", pane_id, "window-status-current-style", &active_tab_style])?; + // Status bar background: update the session-level status-style when this window is focused. + // Uses hook index [99] to avoid conflicting with other pane-focus-in hooks. + let status_bar_style = format!("bg={},fg={}", scheme.status_bg, scheme.status_fg); + let hook_cmd = format!("set-option status-style '{}'", status_bar_style); + let _ = self.tmux_cmd(&["set-hook", "-w", "-t", pane_id, "pane-focus-in[99]", &hook_cmd]); + // Apply immediately for the current window + self.tmux_cmd(&["set-option", "-t", pane_id, "status-style", &status_bar_style])?; + Ok(()) + } + // === Pane Management === fn select_pane(&self, pane_id: &str) -> Result<()> { @@ -796,6 +820,51 @@ impl Multiplexer for TmuxBackend { Ok(panes) } } + +/// A complete color scheme for a tmux window: background, text, borders, and status bar. +struct WindowColorScheme { + /// Subtle background tint + bg: &'static str, + /// Text color (light, complementary to background hue) + fg: &'static str, + /// Pane border color (muted accent) + border: &'static str, + /// Active pane border color (brighter accent) + active_border: &'static str, + /// Status bar tab background for this window + status_bg: &'static str, + /// Status bar tab text color for this window + status_fg: &'static str, +} + +/// Palette of 12 color schemes for per-window tinting. +/// Each scheme uses a tinted dark background with coordinated text, border, and status bar colors. +const WINDOW_COLOR_PALETTE: &[WindowColorScheme] = &[ + WindowColorScheme { bg: "#141430", fg: "#b8b8d0", border: "#3a3a5e", active_border: "#6868b0", status_bg: "#1e1e48", status_fg: "#c8c8e0" }, // blue + WindowColorScheme { bg: "#143014", fg: "#b8d0b8", border: "#3a5e3a", active_border: "#68b068", status_bg: "#1e481e", status_fg: "#c8e0c8" }, // green + WindowColorScheme { bg: "#301414", fg: "#d0b8b8", border: "#5e3a3a", active_border: "#b06868", status_bg: "#481e1e", status_fg: "#e0c8c8" }, // red + WindowColorScheme { bg: "#143030", fg: "#b8d0d0", border: "#3a5e5e", active_border: "#68b0b0", status_bg: "#1e4848", status_fg: "#c8e0e0" }, // cyan + WindowColorScheme { bg: "#303014", fg: "#d0d0b8", border: "#5e5e3a", active_border: "#b0b068", status_bg: "#48481e", status_fg: "#e0e0c8" }, // yellow + WindowColorScheme { bg: "#301430", fg: "#d0b8d0", border: "#5e3a5e", active_border: "#b068b0", status_bg: "#481e48", status_fg: "#e0c8e0" }, // magenta + WindowColorScheme { bg: "#141a28", fg: "#b8c0c8", border: "#3a4858", active_border: "#6878a0", status_bg: "#1e2840", status_fg: "#c8d0d8" }, // slate + WindowColorScheme { bg: "#281a14", fg: "#c8c0b8", border: "#58483a", active_border: "#a07868", status_bg: "#40281e", status_fg: "#d8d0c8" }, // warm + WindowColorScheme { bg: "#142818", fg: "#b8c8c0", border: "#3a5848", active_border: "#68a078", status_bg: "#1e4028", status_fg: "#c8d8d0" }, // forest + WindowColorScheme { bg: "#1a1428", fg: "#c0b8c8", border: "#483a58", active_border: "#7868a0", status_bg: "#281e40", status_fg: "#d0c8d8" }, // purple + WindowColorScheme { bg: "#282814", fg: "#c8c8b8", border: "#58583a", active_border: "#a0a068", status_bg: "#40401e", status_fg: "#d8d8c8" }, // olive + WindowColorScheme { bg: "#141c24", fg: "#b8c0c5", border: "#3a4850", active_border: "#688090", status_bg: "#1e2c38", status_fg: "#c8d0d5" }, // steel +]; + +/// Derive a deterministic palette color scheme from a name string. +/// Uses FNV-1a hash for fast, well-distributed mapping. +fn window_color_for_name(name: &str) -> &'static WindowColorScheme { + let mut hash: u64 = 0xcbf29ce484222325; // FNV offset basis + for byte in name.as_bytes() { + hash ^= *byte as u64; + hash = hash.wrapping_mul(0x100000001b3); // FNV prime + } + &WINDOW_COLOR_PALETTE[(hash as usize) % WINDOW_COLOR_PALETTE.len()] +} + /// Format string to inject into tmux window-status-format. const WORKMUX_STATUS_FORMAT: &str = "#{?@workmux_status, #{@workmux_status},}"; @@ -875,4 +944,45 @@ mod tests { " #I:#W#{?@workmux_status, #{@workmux_status},}#{window_flags} " ); } + + #[test] + fn test_window_color_deterministic() { + // Same name always produces the same color scheme + let scheme1 = window_color_for_name("feature-auth"); + let scheme2 = window_color_for_name("feature-auth"); + assert_eq!(scheme1.bg, scheme2.bg); + assert_eq!(scheme1.fg, scheme2.fg); + assert_eq!(scheme1.border, scheme2.border); + assert_eq!(scheme1.active_border, scheme2.active_border); + } + + #[test] + fn test_window_color_different_names() { + // Different names should (very likely) produce different schemes + let scheme1 = window_color_for_name("feature-auth"); + let scheme2 = window_color_for_name("fix-bug-123"); + let scheme3 = window_color_for_name("refactor-db"); + // At least 2 of 3 should differ + assert!(scheme1.bg != scheme2.bg || scheme2.bg != scheme3.bg); + } + + #[test] + fn test_window_color_scheme_has_all_fields() { + // Every name produces a scheme with non-empty hex color values + for name in &["main", "develop", "feature-x", "hotfix-y", "a", ""] { + let scheme = window_color_for_name(name); + let fields = [ + ("bg", scheme.bg), + ("fg", scheme.fg), + ("border", scheme.border), + ("active_border", scheme.active_border), + ("status_bg", scheme.status_bg), + ("status_fg", scheme.status_fg), + ]; + for (field, value) in &fields { + assert!(!value.is_empty(), "{} empty for '{}'", field, name); + assert!(value.starts_with('#'), "{} not hex for '{}'", field, name); + } + } + } } diff --git a/src/workflow/setup.rs b/src/workflow/setup.rs index 91e8e9ad..f03a7db2 100644 --- a/src/workflow/setup.rs +++ b/src/workflow/setup.rs @@ -183,6 +183,10 @@ pub fn setup_environment( "setup_environment:window created" ); + if config.window_colors.unwrap_or(false) { + let _ = mux.apply_window_color(&initial_pane_id, handle); + } + let result = mux .setup_panes( &initial_pane_id, @@ -241,6 +245,10 @@ pub fn setup_environment( pane_id }; + if config.window_colors.unwrap_or(false) { + let _ = mux.apply_window_color(&initial_pane_id, handle); + } + let result = mux .setup_panes( &initial_pane_id,