Skip to content
Open
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
7 changes: 6 additions & 1 deletion src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
11 changes: 10 additions & 1 deletion src/command/add.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()))?;
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -414,6 +418,7 @@ pub fn run(
deferred_auto_name,
max_concurrent: multi.max_concurrent,
sandbox_override,
colors_override,
prompt_file_only,
};
plan.execute()
Expand Down Expand Up @@ -543,6 +548,7 @@ struct CreationPlan<'a> {
deferred_auto_name: bool,
max_concurrent: Option<u32>,
sandbox_override: bool,
colors_override: bool,
prompt_file_only: bool,
}

Expand Down Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/command/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
6 changes: 5 additions & 1 deletion src/command/open.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub fn run(
run_hooks: bool,
force_files: bool,
new_window: bool,
colors: bool,
session: bool,
prompt_args: PromptArgs,
) -> Result<()> {
Expand All @@ -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)?;

Expand Down
12 changes: 12 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ pub struct Config {
#[serde(default)]
pub status_format: Option<bool>,

/// 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<bool>,

/// Custom icons for agent status display.
#[serde(default)]
pub status_icons: StatusIcons,
Expand Down Expand Up @@ -1504,6 +1510,7 @@ impl Config {
panes,
windows,
status_format,
window_colors,
nerdfont,
auto_update_check,
prompt_file_only,
Expand Down Expand Up @@ -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: "🤖"
Expand Down
7 changes: 7 additions & 0 deletions src/multiplexer/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
110 changes: 110 additions & 0 deletions src/multiplexer/tmux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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},}";

Expand Down Expand Up @@ -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);
}
}
}
}
8 changes: 8 additions & 0 deletions src/workflow/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down