diff --git a/src/app/mod.rs b/src/app/mod.rs index 6802a1d..f1b6141 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -996,6 +996,17 @@ mod tests { Ok(()) } + fn apply_task_status_bar( + &self, + _session_name: &str, + _category_title: &str, + _task_title: &str, + _branch_name: &str, + _color_seed: &str, + ) -> Result<()> { + Ok(()) + } + fn switch_client( &self, session_name: &str, diff --git a/src/app/runtime.rs b/src/app/runtime.rs index 9f5b577..159c1bb 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -10,8 +10,9 @@ use crate::git::{ }; use crate::process::command; use crate::tmux::{ - PopupThemeStyle, sanitize_session_name_for_project, tmux_create_session, tmux_kill_session, - tmux_open_session_in_new_terminal, tmux_session_exists, tmux_show_popup, tmux_switch_client, + PopupThemeStyle, sanitize_session_name_for_project, tmux_apply_task_status_bar, + tmux_create_session, tmux_kill_session, tmux_open_session_in_new_terminal, tmux_session_exists, + tmux_show_popup, tmux_switch_client, }; /// Runtime trait for task recovery operations @@ -20,6 +21,14 @@ pub trait RecoveryRuntime { fn worktree_exists(&self, worktree_path: &Path) -> bool; fn session_exists(&self, session_name: &str) -> bool; fn create_session(&self, session_name: &str, working_dir: &Path, command: &str) -> Result<()>; + fn apply_task_status_bar( + &self, + session_name: &str, + category_title: &str, + task_title: &str, + branch_name: &str, + color_seed: &str, + ) -> Result<()>; fn switch_client( &self, session_name: &str, @@ -56,6 +65,23 @@ impl RecoveryRuntime for RealRecoveryRuntime { tmux_create_session(session_name, working_dir, Some(command)) } + fn apply_task_status_bar( + &self, + session_name: &str, + category_title: &str, + task_title: &str, + branch_name: &str, + color_seed: &str, + ) -> Result<()> { + tmux_apply_task_status_bar( + session_name, + category_title, + task_title, + branch_name, + color_seed, + ) + } + fn switch_client( &self, session_name: &str, @@ -109,6 +135,14 @@ pub trait CreateTaskRuntime { working_dir: &Path, command: Option<&str>, ) -> Result<()>; + fn tmux_apply_task_status_bar( + &self, + session_name: &str, + category_title: &str, + task_title: &str, + branch_name: &str, + color_seed: &str, + ) -> Result<()>; fn tmux_kill_session(&self, session_name: &str) -> Result<()>; } @@ -225,6 +259,23 @@ impl CreateTaskRuntime for RealCreateTaskRuntime { tmux_create_session(session_name, working_dir, command) } + fn tmux_apply_task_status_bar( + &self, + session_name: &str, + category_title: &str, + task_title: &str, + branch_name: &str, + color_seed: &str, + ) -> Result<()> { + tmux_apply_task_status_bar( + session_name, + category_title, + task_title, + branch_name, + color_seed, + ) + } + fn tmux_kill_session(&self, session_name: &str) -> Result<()> { tmux_kill_session(session_name) } @@ -396,6 +447,17 @@ mod tests { Ok(()) } + fn apply_task_status_bar( + &self, + _session_name: &str, + _category_title: &str, + _task_title: &str, + _branch_name: &str, + _color_seed: &str, + ) -> Result<()> { + Ok(()) + } + fn switch_client( &self, _session_name: &str, diff --git a/src/app/workflows/attach.rs b/src/app/workflows/attach.rs index ac86120..e34f080 100644 --- a/src/app/workflows/attach.rs +++ b/src/app/workflows/attach.rs @@ -1,6 +1,6 @@ use std::path::{Path, PathBuf}; -use anyhow::Result; +use anyhow::{Context, Result}; use tracing::warn; use tuirealm::ratatui::style::Color; use uuid::Uuid; @@ -91,9 +91,20 @@ fn ensure_task_session_with_runtime( )); } + let category_title = task_status_bar_category_title(db, task); + if let Some(session_name) = task.tmux_session_name.as_deref() && runtime.session_exists(session_name) { + runtime + .apply_task_status_bar( + session_name, + &category_title, + &task.title, + &task.branch, + &task.id.to_string(), + ) + .context("failed to configure task tmux status bar")?; let working_dir = task .worktree_path .as_deref() @@ -131,6 +142,15 @@ fn ensure_task_session_with_runtime( ); runtime.create_session(&session_name, worktree_path, &command)?; + runtime + .apply_task_status_bar( + &session_name, + &category_title, + &task.title, + &task.branch, + &task.id.to_string(), + ) + .context("failed to configure task tmux status bar")?; db.update_task_tmux( task.id, Some(session_name.clone()), @@ -144,6 +164,21 @@ fn ensure_task_session_with_runtime( })) } +fn task_status_bar_category_title(db: &Database, task: &Task) -> String { + match db.get_category(task.category_id) { + Ok(category) => category.name, + Err(err) => { + warn!( + error = %err, + task_id = %task.id, + category_id = %task.category_id, + "failed to load task category for tmux status bar" + ); + "TASK".to_string() + } + } +} + fn maybe_show_attach_popup( db: &Database, task_id: Uuid, diff --git a/src/app/workflows/create_task.rs b/src/app/workflows/create_task.rs index bdb4428..4b1d791 100644 --- a/src/app/workflows/create_task.rs +++ b/src/app/workflows/create_task.rs @@ -153,6 +153,10 @@ pub(crate) fn create_task_pipeline_with_runtime( let mut created_task_id: Option = None; let branch_name = branch.clone(); let resolved_title = resolve_task_title(state.title_input.trim(), &branch_name); + let category_title = db + .get_category(todo_category_id) + .context("failed to load task category")? + .name; let mut operation = || -> Result<()> { let session_name = @@ -172,6 +176,16 @@ pub(crate) fn create_task_pipeline_with_runtime( .context("failed to save task")?; created_task_id = Some(task.id); + runtime + .tmux_apply_task_status_bar( + &session_name, + &category_title, + &task.title, + &task.branch, + &task.id.to_string(), + ) + .context("failed to configure task tmux status bar")?; + db.update_task_tmux( task.id, Some(session_name.clone()), diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e3b0db0..0c73c93 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -516,6 +516,11 @@ fn task_create(db: &Database, project: &str, args: TaskCreateArgs) -> CliResult< Some(value) => value, None => resolve_default_category_id(db)?, }; + let category_title = db + .get_category(category_id) + .context("failed to load task category") + .map_err(classify_db_error)? + .name; let branch = args.branch.trim(); if branch.is_empty() { @@ -637,6 +642,16 @@ fn task_create(db: &Database, project: &str, args: TaskCreateArgs) -> CliResult< .context("failed to save task")?; created_task_id = Some(task.id); + CreateTaskRuntime::tmux_apply_task_status_bar( + &runtime, + &session_name, + &category_title, + &task.title, + &task.branch, + &task.id.to_string(), + ) + .context("failed to configure task tmux status bar")?; + db.update_task_tmux( task.id, Some(session_name.clone()), diff --git a/src/db/mod.rs b/src/db/mod.rs index fd3aca0..0aedfc3 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -1046,7 +1046,7 @@ impl Database { map_repo_row(&row) } - async fn get_category_async(&self, id: Uuid) -> Result { + pub async fn get_category_async(&self, id: Uuid) -> Result { let row = sqlx::query( "SELECT id, slug, name, position, color, created_at FROM categories WHERE id = ?", ) @@ -1057,6 +1057,10 @@ impl Database { let row = row.with_context(|| format!("category {id} not found"))?; map_category_row(&row) } + + pub fn get_category(&self, id: Uuid) -> Result { + block_on_db(self.get_category_async(id)) + } } fn sqlite_connect_options(path_ref: &Path) -> Result { diff --git a/src/tmux/mod.rs b/src/tmux/mod.rs index ca60cd1..ea42586 100644 --- a/src/tmux/mod.rs +++ b/src/tmux/mod.rs @@ -9,6 +9,9 @@ use termlauncher::{Application, CustomTerminal, Error as TermlauncherError, Term use crate::process::{command, tmux_env_args}; +mod status_bar; +pub use status_bar::{TaskStatusBarSpec, render_task_status_bar, tmux_apply_task_status_bar}; + const TMUX_SOCKET: &str = ""; #[derive(Debug, Clone, Eq, PartialEq)] diff --git a/src/tmux/status_bar.rs b/src/tmux/status_bar.rs new file mode 100644 index 0000000..686eb84 --- /dev/null +++ b/src/tmux/status_bar.rs @@ -0,0 +1,200 @@ +use anyhow::{Context, Result}; + +use super::{ensure_success_with_output, tmux_command}; + +const STATUS_BG: &str = "colour234"; +const STATUS_FG: &str = "colour252"; +const STATUS_MUTED_FG: &str = "colour245"; + +const ACCENT_COLORS: &[AccentColor] = &[ + AccentColor { bg: 33, fg: 231 }, + AccentColor { bg: 37, fg: 16 }, + AccentColor { bg: 64, fg: 231 }, + AccentColor { bg: 67, fg: 231 }, + AccentColor { bg: 99, fg: 231 }, + AccentColor { bg: 135, fg: 231 }, + AccentColor { bg: 166, fg: 16 }, + AccentColor { bg: 172, fg: 16 }, + AccentColor { bg: 203, fg: 231 }, + AccentColor { bg: 209, fg: 16 }, + AccentColor { bg: 214, fg: 16 }, + AccentColor { bg: 70, fg: 16 }, + AccentColor { bg: 75, fg: 16 }, + AccentColor { bg: 141, fg: 16 }, +]; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +struct AccentColor { + bg: u8, + fg: u8, +} + +#[derive(Debug, Clone, Eq, PartialEq)] +pub struct TaskStatusBarSpec { + pub status_left: String, + pub status_right: String, + pub status_style: String, +} + +pub fn tmux_apply_task_status_bar( + session_name: &str, + category_title: &str, + task_title: &str, + branch_name: &str, + color_seed: &str, +) -> Result<()> { + for args in task_status_bar_args( + session_name, + category_title, + task_title, + branch_name, + color_seed, + ) { + let output = tmux_command() + .args(args) + .output() + .context("failed to configure tmux task status bar")?; + ensure_success_with_output(&output, "set-option")?; + } + + Ok(()) +} + +pub fn render_task_status_bar( + category_title: &str, + task_title: &str, + branch_name: &str, + color_seed: &str, +) -> TaskStatusBarSpec { + let accent = accent_for_seed(color_seed); + let category = status_badge_title(category_title); + let title = tmux_status_text(task_title, 96); + let branch = tmux_status_text(branch_name, 72); + + TaskStatusBarSpec { + status_left: format!( + "#[fg=colour{},bg=colour{}] {} #[fg={STATUS_FG},bg={STATUS_BG}] {} ", + accent.fg, accent.bg, category, title + ), + status_right: if branch.is_empty() { + String::new() + } else { + format!("#[fg={STATUS_MUTED_FG},bg={STATUS_BG}] {branch} ") + }, + status_style: format!("fg={STATUS_FG},bg={STATUS_BG}"), + } +} + +fn task_status_bar_args( + session_name: &str, + category_title: &str, + task_title: &str, + branch_name: &str, + color_seed: &str, +) -> Vec> { + let spec = render_task_status_bar(category_title, task_title, branch_name, color_seed); + + vec![ + set_option_args(session_name, "status", "on"), + set_option_args(session_name, "status-position", "bottom"), + set_option_args(session_name, "status-style", &spec.status_style), + set_option_args(session_name, "status-left-length", "160"), + set_option_args(session_name, "status-right-length", "96"), + set_option_args(session_name, "status-left", &spec.status_left), + set_option_args(session_name, "status-right", &spec.status_right), + ] +} + +fn set_option_args(session_name: &str, option: &str, value: &str) -> Vec { + vec![ + "set-option".to_string(), + "-t".to_string(), + session_name.to_string(), + option.to_string(), + value.to_string(), + ] +} + +fn accent_for_seed(seed: &str) -> AccentColor { + let hash = fnv1a(seed.trim().as_bytes()); + ACCENT_COLORS[hash as usize % ACCENT_COLORS.len()] +} + +fn fnv1a(bytes: &[u8]) -> u64 { + let mut hash = 0xcbf2_9ce4_8422_2325; + for byte in bytes { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash +} + +fn status_badge_title(title: &str) -> String { + let title = tmux_status_text(title, 24); + if title.is_empty() { + "TASK".to_string() + } else { + title.to_uppercase() + } +} + +fn tmux_status_text(value: &str, max_chars: usize) -> String { + let normalized = value.split_whitespace().collect::>().join(" "); + let mut chars = normalized.chars(); + let mut text = chars.by_ref().take(max_chars).collect::(); + if chars.next().is_some() { + text.push('…'); + } + text.replace('#', "##") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn render_status_bar_uses_category_title_task_title_and_branch() { + let spec = render_task_status_bar( + "In Progress", + "Custom tmux bottom bar", + "feat/tmux-status", + "task-1", + ); + + assert!(spec.status_left.contains(" IN PROGRESS ")); + assert!(spec.status_left.contains("Custom tmux bottom bar")); + assert!(spec.status_right.contains("feat/tmux-status")); + assert_eq!(spec.status_style, "fg=colour252,bg=colour234"); + } + + #[test] + fn render_status_bar_escapes_tmux_hashes() { + let spec = render_task_status_bar("Review", "Fix #123 #[bad]", "feat/#hash", "task-2"); + + assert!(spec.status_left.contains("Fix ##123 ##[bad]")); + assert!(spec.status_right.contains("feat/##hash")); + } + + #[test] + fn render_status_bar_uses_stable_accent_for_same_seed() { + let first = render_task_status_bar("Todo", "One", "branch", "same-task"); + let second = render_task_status_bar("Todo", "Two", "other", "same-task"); + + let first_badge_style = first.status_left.split(']').next(); + let second_badge_style = second.status_left.split(']').next(); + assert_eq!(first_badge_style, second_badge_style); + } + + #[test] + fn task_status_bar_args_are_session_local_set_options() { + let args = task_status_bar_args("ok-session", "Todo", "Task", "branch", "seed"); + + assert!(args.iter().all(|arg| arg[0] == "set-option")); + assert!(args.iter().all(|arg| arg[1] == "-t")); + assert!(args.iter().all(|arg| arg[2] == "ok-session")); + assert!( + args.iter() + .any(|arg| arg[3] == "status-position" && arg[4] == "bottom") + ); + } +}