diff --git a/README.md b/README.md index 0345e6ec..13d90e5f 100644 --- a/README.md +++ b/README.md @@ -162,7 +162,7 @@ does not inject keystrokes into the launched program. Background terminal tabs show an activity spinner for five seconds after receiving output, including animated titles from tools such as Codex and output in renamed tabs. Viewing a tab clears its activity; leaving it does not restart the spinner -until new output arrives. A blue notification dot replaces the spinner until the +until new output arrives. A notification dot replaces the spinner until the notification is read. In **Settings > General**, turn off **Tab activity indicators** or adjust **Tab activity timeout** from 1 to 60 seconds. Cursor blinking and scrolling alone do not count as output. diff --git a/rust/limux-cli/src/main.rs b/rust/limux-cli/src/main.rs index 92fb2660..186ae8c7 100644 --- a/rust/limux-cli/src/main.rs +++ b/rust/limux-cli/src/main.rs @@ -1020,88 +1020,74 @@ async fn run_agent_hook( serde_json::from_str(raw).unwrap_or_else(|_| json!({ "raw": raw })) }; - // Explicit --event or positional event beats the JSON field. - let event = parse_hook_event(args, &payload); + let mut event = parse_hook_event(args, &payload); + // Older installers routed Claude Notification through positional stop. + // Preserve that payload's identity while honoring an explicit --event. + if agent == agent_hooks::AgentKind::Claude + && event == "stop" + && parse_opt(args, "--event").is_none() + && hook_str(&payload, &["hook_event_name"]) == Some("Notification") + { + event = "Notification".to_string(); + } // Build a human-friendly title + body depending on event + agent. let agent_label = agent.label(); persist_agent_hook_session(agent, args, &payload, &event)?; - let (title, body) = match event.as_str() { - "Notification" => ( + let notification_title_body = match event.as_str() { + "Notification" | "notification" => Some(( format!("{agent_label} needs you"), hook_str(&payload, &["message", "notification"]) .unwrap_or("waiting for input") .to_owned(), - ), - "Stop" | "SubagentStop" => ( + )), + "Stop" | "stop" | "finished" => Some(( format!("{agent_label} finished"), hook_str(&payload, &["message", "reason"]) .unwrap_or("task complete") .to_owned(), - ), - "SessionStart" => ( - format!("{agent_label} session started"), - hook_str(&payload, &["cwd", "source"]) - .unwrap_or("") - .to_owned(), - ), - "SessionEnd" => ( - format!("{agent_label} session ended"), - hook_str(&payload, &["reason"]).unwrap_or("").to_owned(), - ), - "PreToolUse" | "PostToolUse" => ( - format!( - "{agent_label}: {}", - hook_str(&payload, &["tool_name"]).unwrap_or("tool") - ), - hook_str(&payload, &["tool_input", "summary"]) - .unwrap_or("") - .to_owned(), - ), - "UserPromptSubmit" => ( - format!("{agent_label}: new prompt"), - hook_str(&payload, &["prompt"]) - .unwrap_or("") - .chars() - .take(120) - .collect(), - ), - other => ( + )), + "SessionStart" | "session-start" | "SessionEnd" | "session-end" | "UserPromptSubmit" + | "prompt-submit" | "PreToolUse" | "PostToolUse" | "SubagentStop" | "subagent-stop" + | "Cleanup" | "cleanup" | "restore-exit" => None, + other => Some(( format!("{agent_label}: {other}"), hook_str(&payload, &["message", "summary"]) .unwrap_or("") .to_owned(), - ), + )), }; - let subtitle = hook_str(&payload, &["session_id"]) - .map(|s| { - // Show only a short prefix of the session id to keep sidebar tidy. - s.chars().take(8).collect::() - }) - .unwrap_or_default(); + if let Some((title, body)) = notification_title_body { + let subtitle = hook_str(&payload, &["session_id"]) + .map(|s| { + // Show only a short prefix of the session id to keep sidebar tidy. + s.chars().take(8).collect::() + }) + .unwrap_or_default(); - let (workspace, surface) = notification_scope(args, env_opt); + let (workspace, surface) = notification_scope(args, env_opt); - let mut params = Map::new(); - params.insert("title".to_string(), Value::String(title)); - if !subtitle.is_empty() { - params.insert("subtitle".to_string(), Value::String(subtitle)); - } - if !body.is_empty() { - params.insert("body".to_string(), Value::String(body)); - } - if let Some(surface) = surface { - params.insert("surface_id".to_string(), Value::String(surface)); - } + let mut params = Map::new(); + params.insert("title".to_string(), Value::String(title)); + if !subtitle.is_empty() { + params.insert("subtitle".to_string(), Value::String(subtitle)); + } + if !body.is_empty() { + params.insert("body".to_string(), Value::String(body)); + } + if let Some(surface) = surface { + params.insert("surface_id".to_string(), Value::String(surface)); + } - let _ = call_in_workspace_scope( - client, - workspace, - "notification.create", - Value::Object(params), - ) - .await; + let _ = call_in_workspace_scope( + client, + workspace, + "notification.create", + Value::Object(params), + ) + .await; + } Ok(agent_hook_output(&event, &payload)) } @@ -1585,7 +1571,7 @@ fn install_hook_target(agent: agent_hooks::AgentKind) -> Result<()> { ("SessionStart", "session-start"), ("UserPromptSubmit", "prompt-submit"), ("Stop", "stop"), - ("Notification", "stop"), + ("Notification", "notification"), ("SessionEnd", "session-end"), ], ), diff --git a/rust/limux-cli/tests/agent_hook_notifications.rs b/rust/limux-cli/tests/agent_hook_notifications.rs new file mode 100644 index 00000000..16ac692a --- /dev/null +++ b/rust/limux-cli/tests/agent_hook_notifications.rs @@ -0,0 +1,116 @@ +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixListener; +use std::path::Path; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; + +fn private_command(program: &str, home: &Path, socket: &Path) -> Command { + let mut command = Command::new(program); + command + .env_clear() + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join("config")) + .env("XDG_DATA_HOME", home.join("data")) + .env("XDG_STATE_HOME", home.join("state")) + .env("LIMUX_SOCKET", socket) + .env("PATH", "/usr/bin:/bin"); + command +} + +fn notification_params(mut command: Command, socket: &Path, payload: Value) -> Value { + let listener = UnixListener::bind(socket).unwrap(); + listener.set_nonblocking(true).unwrap(); + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + child + .stdin + .take() + .unwrap() + .write_all(payload.to_string().as_bytes()) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(5); + let mut stream = loop { + match listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => { + if Instant::now() >= deadline { + let _ = child.kill(); + let output = child.wait_with_output().unwrap(); + panic!("hook sent no notification: {output:?}"); + } + std::thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("accept hook request: {error}"), + } + }; + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut line = String::new(); + BufReader::new(stream.try_clone().unwrap()) + .read_line(&mut line) + .unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["method"], "notification.create"); + writeln!( + stream, + "{}", + json!({"id": request["id"], "ok": true, "result": {}}) + ) + .unwrap(); + let output = child.wait_with_output().unwrap(); + assert!(output.status.success(), "{output:?}"); + std::fs::remove_file(socket).unwrap(); + request["params"].clone() +} + +#[test] +fn installed_claude_hooks_distinguish_notifications_from_completion() { + let home = tempfile::tempdir().unwrap(); + let socket = home.path().join("hook.sock"); + let cli = env!("CARGO_BIN_EXE_limux-cli"); + let output = private_command(cli, home.path(), &socket) + .args(["hooks", "setup", "claude"]) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + let settings: Value = + serde_json::from_slice(&std::fs::read(home.path().join(".claude/settings.json")).unwrap()) + .unwrap(); + for (event, title, body) in [ + ("Notification", "Claude needs you", "waiting for input"), + ("Stop", "Claude finished", "task complete"), + ] { + let hook = settings["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + let mut command = private_command("/bin/sh", home.path(), &socket); + command.args(["-c", hook]); + let params = notification_params(command, &socket, json!({"hook_event_name": event})); + assert_eq!(params["title"], title, "installed {event} hook"); + assert_eq!(params["body"], body, "installed {event} hook"); + } +} + +#[test] +fn legacy_claude_stop_route_respects_notification_payload_and_explicit_event() { + let home = tempfile::tempdir().unwrap(); + let socket = home.path().join("hook.sock"); + let cli = env!("CARGO_BIN_EXE_limux-cli"); + for (args, event, title) in [ + (vec!["stop"], "Notification", "Claude needs you"), + (vec!["stop"], "Stop", "Claude finished"), + (vec!["--event", "stop"], "Notification", "Claude finished"), + ] { + let mut command = private_command(cli, home.path(), &socket); + command.args(["--json", "hooks", "claude"]).args(args); + let params = notification_params(command, &socket, json!({"hook_event_name": event})); + assert_eq!(params["title"], title); + } +} diff --git a/rust/limux-host-linux/src/app_config.rs b/rust/limux-host-linux/src/app_config.rs index 223dcf64..77385064 100644 --- a/rust/limux-host-linux/src/app_config.rs +++ b/rust/limux-host-linux/src/app_config.rs @@ -92,12 +92,80 @@ pub struct WorkspaceConfig { pub prompt_for_folder_on_create: bool, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UnreadColor { + #[default] + Amber, + Emerald, + Rose, + Cyan, + AccentBlue, +} + +impl UnreadColor { + pub fn as_str(self) -> &'static str { + match self { + Self::Amber => "amber", + Self::Emerald => "emerald", + Self::Rose => "rose", + Self::Cyan => "cyan", + Self::AccentBlue => "accent_blue", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "amber" => Some(Self::Amber), + "emerald" => Some(Self::Emerald), + "rose" => Some(Self::Rose), + "cyan" => Some(Self::Cyan), + "accent_blue" => Some(Self::AccentBlue), + _ => None, + } + } + + pub fn css_color(self) -> &'static str { + match self { + Self::Amber => "#f59e0b", + Self::Emerald => "#10b981", + Self::Rose => "#f43f5e", + Self::Cyan => "#06b6d4", + Self::AccentBlue => "@accent_bg_color", + } + } + + pub fn labels() -> &'static [&'static str] { + &["Amber (Default)", "Emerald", "Rose", "Cyan", "Accent Blue"] + } + + pub fn dropdown_index(self) -> u32 { + match self { + Self::Amber => 0, + Self::Emerald => 1, + Self::Rose => 2, + Self::Cyan => 3, + Self::AccentBlue => 4, + } + } + + pub fn from_dropdown_index(index: u32) -> Self { + match index { + 1 => Self::Emerald, + 2 => Self::Rose, + 3 => Self::Cyan, + 4 => Self::AccentBlue, + _ => Self::Amber, + } + } +} + #[derive(Clone, Debug, PartialEq)] pub struct AppearanceConfig { pub color_scheme: ColorScheme, pub ghostty_color_scheme: ColorScheme, pub ui_scale: UiScale, pub show_workspace_path: bool, + pub unread_color: UnreadColor, } impl Default for AppearanceConfig { @@ -107,6 +175,7 @@ impl Default for AppearanceConfig { ghostty_color_scheme: ColorScheme::default(), ui_scale: UiScale::default(), show_workspace_path: true, + unread_color: UnreadColor::default(), } } } @@ -417,6 +486,12 @@ fn parse_app_config_value(root: &Value) -> AppConfig { .and_then(Value::as_bool) .unwrap_or(true); + let unread_color = appearance + .and_then(|appearance| appearance.get("unread_color")) + .and_then(Value::as_str) + .and_then(UnreadColor::from_str) + .unwrap_or_default(); + let workspace = root.get("workspace").and_then(Value::as_object); let keep_open_after_last_terminal_closes = workspace .and_then(|workspace| workspace.get("keep_open_after_last_terminal_closes")) @@ -502,6 +577,7 @@ fn parse_app_config_value(root: &Value) -> AppConfig { ghostty_color_scheme, ui_scale, show_workspace_path, + unread_color, }, workspace: WorkspaceConfig { keep_open_after_last_terminal_closes, @@ -557,6 +633,12 @@ fn save_to_path(path: &Path, config: &AppConfig) -> Result<(), String> { json!(config.appearance.ui_scale.get()), ); } + if config.appearance.unread_color != UnreadColor::default() { + appearance.insert( + "unread_color".to_string(), + json!(config.appearance.unread_color.as_str()), + ); + } root.insert("appearance".to_string(), Value::Object(appearance)); root.insert( "focus".to_string(), @@ -976,6 +1058,28 @@ mod tests { assert_eq!(loaded.config.appearance.ui_scale.get(), MAX_UI_SCALE); } + #[test] + fn save_and_load_round_trips_unread_color() { + let dir = TempDir::new().expect("temp dir"); + let path = settings_path_in(dir.path()); + let mut config = AppConfig::default(); + assert_eq!(config.appearance.unread_color, UnreadColor::Amber); + + for color in [ + UnreadColor::Amber, + UnreadColor::Emerald, + UnreadColor::Rose, + UnreadColor::Cyan, + UnreadColor::AccentBlue, + ] { + config.appearance.unread_color = color; + save_to_path(&path, &config).expect("save config"); + let loaded = load_from_path(&path); + assert!(loaded.warnings.is_empty()); + assert_eq!(loaded.config.appearance.unread_color, color); + } + } + #[test] fn load_from_path_reads_font_size_when_valid() { let dir = TempDir::new().expect("temp dir"); diff --git a/rust/limux-host-linux/src/pane.rs b/rust/limux-host-linux/src/pane.rs index 922eb858..92230fc9 100644 --- a/rust/limux-host-linux/src/pane.rs +++ b/rust/limux-host-linux/src/pane.rs @@ -457,7 +457,7 @@ pub const PANE_CSS: &str = r#" margin-right: 2px; } .limux-tab-unread-dot { - color: @accent_bg_color; + color: #f59e0b; font-size: 9px; margin-right: 2px; } @@ -783,7 +783,7 @@ pub fn focus_active_tab_in_pane(pane_widget: >k::Widget) -> bool { &internals.tab_state, &tab_id, ); - clear_tab_unread_if_visible( + clear_tab_unread_if_visible_and_not_manual( &internals.tab_state, &tab_id, &internals.pane_outer.clone().upcast(), @@ -938,6 +938,9 @@ pub fn set_tab_pinned_in_pane(pane_widget: >k::Widget, tab_id: &str, pinned: b } fn set_tab_unread(entry: &mut TabEntry, unread: bool) -> bool { + if !unread { + entry.manual_unread = false; + } if entry.unread == unread { return false; } @@ -947,6 +950,11 @@ fn set_tab_unread(entry: &mut TabEntry, unread: bool) -> bool { true } +fn set_tab_manual_unread(entry: &mut TabEntry, unread: bool) -> bool { + entry.manual_unread = unread; + set_tab_unread(entry, unread) +} + fn clear_tab_unread(tab_state: &Rc>, tab_id: &str) -> bool { tab_state .borrow_mut() @@ -954,6 +962,17 @@ fn clear_tab_unread(tab_state: &Rc>, tab_id: &str) -> bool { .is_some_and(|entry| set_tab_unread(entry, false)) } +fn clear_tab_unread_if_not_manual(tab_state: &Rc>, tab_id: &str) -> bool { + let mut ts = tab_state.borrow_mut(); + let Some(entry) = ts.find_tab_mut(tab_id) else { + return false; + }; + if entry.manual_unread { + return false; + } + set_tab_unread(entry, false) +} + fn clear_tab_unread_if_visible( tab_state: &Rc>, tab_id: &str, @@ -965,6 +984,18 @@ fn clear_tab_unread_if_visible( } } +fn clear_tab_unread_if_visible_and_not_manual( + tab_state: &Rc>, + tab_id: &str, + pane_widget: >k::Widget, + callbacks: &Rc, +) { + if (callbacks.is_pane_visible)(pane_widget) && clear_tab_unread_if_not_manual(tab_state, tab_id) + { + (callbacks.on_unread_changed)(); + } +} + fn normalize_surface_hint(raw: &str) -> &str { raw.trim() .strip_prefix("surface:") @@ -1158,6 +1189,7 @@ struct TabEntry { automatic_title: Option, pinned: bool, unread: bool, + manual_unread: bool, kind: TabKind, } @@ -1984,6 +2016,7 @@ fn add_terminal_tab_inner( automatic_title: None, pinned: options.as_ref().map(|value| value.pinned).unwrap_or(false), unread: false, + manual_unread: false, kind: TabKind::Terminal { state: TerminalTabState { ssh, @@ -2169,6 +2202,7 @@ fn add_browser_tab_inner(internals: &Rc, options: Option, input: KeybindsTa .map(|value| value.pinned) .unwrap_or(false), unread: false, + manual_unread: false, kind: TabKind::Keybinds, }); } @@ -2608,6 +2643,56 @@ pub fn clear_active_tab_unread_in_root(root: >k::Widget) -> bool { changed } +pub fn clear_active_tab_unread_in_root_if_not_manual(root: >k::Widget) -> bool { + let mut changed = false; + for internals in pane_internals_for_root(root) { + let active_tab = internals.tab_state.borrow().active_tab.clone(); + if let Some(tab_id) = active_tab { + changed |= clear_tab_unread_if_not_manual(&internals.tab_state, &tab_id); + } + } + changed +} + +pub fn clear_all_tabs_unread_in_root(root: >k::Widget) -> bool { + let mut changed = false; + for internals in pane_internals_for_root(root) { + let tab_ids: Vec = internals + .tab_state + .borrow() + .tabs + .iter() + .filter(|entry| entry.unread) + .map(|entry| entry.id.clone()) + .collect(); + for tab_id in tab_ids { + changed |= clear_tab_unread(&internals.tab_state, &tab_id); + } + } + changed +} + +pub fn mark_active_tab_manual_unread_in_root(root: >k::Widget) -> bool { + let mut changed = false; + for internals in pane_internals_for_root(root) { + let active_tab = internals.tab_state.borrow().active_tab.clone().or_else(|| { + internals + .tab_state + .borrow() + .tabs + .first() + .map(|e| e.id.clone()) + }); + if let Some(tab_id) = active_tab { + if let Some(entry) = internals.tab_state.borrow_mut().find_tab_mut(&tab_id) { + changed |= set_tab_manual_unread(entry, true); + break; + } + } + } + changed +} + pub fn workspace_has_unread_tabs(workspace_id: &str) -> bool { pane_internals_for_workspace(workspace_id) .into_iter() @@ -2960,15 +3045,45 @@ fn build_tab_button_from_label( } tab_btn.add_controller(right_click); - // Middle-click to close the tab. + // Middle-click to close the tab (on release inside bounds, unless pinned or renaming). let middle_click = gtk::GestureClick::new(); middle_click.set_button(2); { - let tab_id = tab_id.to_string(); - let pane_outer = internals.pane_outer.clone(); + let tab_button = tab_btn.downgrade(); + let tab_state = internals.tab_state.clone(); middle_click.connect_pressed(move |gesture, _, _, _| { + let Some(tab_button) = tab_button.upgrade() else { + return; + }; + if handle_tab_interaction_while_renaming(&tab_button, &tab_state) { + gesture.set_state(gtk::EventSequenceState::Denied); + return; + } gesture.set_state(gtk::EventSequenceState::Claimed); - close_tab_in_pane(pane_outer.upcast_ref(), &tab_id); + }); + } + { + let tab_id = tab_id.to_string(); + let tab_state = internals.tab_state.clone(); + let pane_outer = internals.pane_outer.downgrade(); + let tab_button = tab_btn.downgrade(); + middle_click.connect_released(move |_, _, x, y| { + let Some(tab_button) = tab_button.upgrade() else { + return; + }; + let Some(pane_outer) = pane_outer.upgrade() else { + return; + }; + let is_renaming = tab_rename_active(&tab_state); + let inside_bounds = tab_button.contains(x, y); + let is_pinned = tab_state + .borrow() + .tabs + .iter() + .any(|entry| entry.id == tab_id && entry.pinned); + if can_middle_click_close_tab(is_pinned, is_renaming, inside_bounds) { + close_tab_in_pane(pane_outer.upcast_ref(), &tab_id); + } }); } tab_btn.add_controller(middle_click); @@ -3045,6 +3160,10 @@ fn build_tab_button_from_label( (tab_btn, unread_dot) } +fn can_middle_click_close_tab(is_pinned: bool, is_renaming: bool, inside_bounds: bool) -> bool { + !is_pinned && !is_renaming && inside_bounds +} + fn show_tab_context_menu(tab_btn: >k::Box, tab_id: &str, context: &TabContextMenuContext) { let menu = gtk::PopoverMenu::from_model(None::<>k::gio::MenuModel>); let menu_box = gtk::Box::new(gtk::Orientation::Vertical, 2); @@ -3053,6 +3172,45 @@ fn show_tab_context_menu(tab_btn: >k::Box, tab_id: &str, context: &TabContextM menu_box.set_margin_start(4); menu_box.set_margin_end(4); + // Mark as Unread / Read + let is_unread = context + .tab_state + .borrow() + .tabs + .iter() + .any(|e| e.id == tab_id && e.unread); + let unread_label = if is_unread { + "Mark as Read" + } else { + "Mark as Unread" + }; + let unread_btn = gtk::Button::with_label(unread_label); + unread_btn.add_css_class("flat"); + { + let state = context.tab_state.clone(); + let tid = tab_id.to_string(); + let menu_ref = menu.downgrade(); + let callbacks = context.callbacks.clone(); + unread_btn.connect_clicked(move |_| { + if let Some(menu) = menu_ref.upgrade() { + menu.popdown(); + } + let state = state.clone(); + let tid = tid.clone(); + let callbacks = callbacks.clone(); + glib::idle_add_local_once(move || { + let mut changed = false; + if let Some(entry) = state.borrow_mut().find_tab_mut(&tid) { + changed = set_tab_manual_unread(entry, !is_unread); + } + if changed { + (callbacks.on_unread_changed)(); + (callbacks.on_state_changed)(); + } + }); + }); + } + // Rename let rename_btn = gtk::Button::with_label("Rename"); rename_btn.add_css_class("flat"); @@ -3141,6 +3299,7 @@ fn show_tab_context_menu(tab_btn: >k::Box, tab_id: &str, context: &TabContextM }); } + menu_box.append(&unread_btn); menu_box.append(&rename_btn); menu_box.append(&pin_btn); menu_box.append(&close_btn); @@ -5243,6 +5402,14 @@ mod tests { ); } + #[test] + fn middle_click_tab_close_guards_against_pinned_renaming_and_outside_release() { + assert!(super::can_middle_click_close_tab(false, false, true)); + assert!(!super::can_middle_click_close_tab(true, false, true)); + assert!(!super::can_middle_click_close_tab(false, true, true)); + assert!(!super::can_middle_click_close_tab(false, false, false)); + } + #[test] #[ignore = "requires a graphical display"] fn moved_tab_survives_either_pane_closing_before_the_next_frame() { diff --git a/rust/limux-host-linux/src/pane_close_tests.rs b/rust/limux-host-linux/src/pane_close_tests.rs index 69acbeb2..fec15961 100644 --- a/rust/limux-host-linux/src/pane_close_tests.rs +++ b/rust/limux-host-linux/src/pane_close_tests.rs @@ -608,3 +608,231 @@ fn inline_rename_commit_returns_the_focus_to_the_workspace() { pump_for(std::time::Duration::from_millis(200)); assert_focus_within("rename committed by a click", &state, &clicked); } + +#[test] +#[ignore = "requires an active X11 window and Ghostty resources"] +fn passive_rename_focus_restore_preserves_manual_tab_unread() { + let temp = tempfile::tempdir().unwrap(); + for key in ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"] { + let path = temp.path().join(key); + std::fs::create_dir_all(&path).unwrap(); + std::env::set_var(key, path); + } + let socket = temp.path().join("limux.sock"); + std::env::set_var("LIMUX_SOCKET", &socket); + std::env::set_var("LIMUX_SOCKET_PATH", &socket); + crate::prepare_ghostty_runtime(); + adw::init().unwrap(); + crate::terminal::init_ghostty(); + let app = adw::Application::builder() + .application_id("dev.limux.UnreadFocusRestoreTest") + .build(); + app.register(None::<&gio::Cancellable>).unwrap(); + build_window(&app); + let state = CONTROL_STATE.with(|slot| slot.borrow().as_ref().unwrap().clone()); + let (ws_id, root) = + show_focused_workspace(&state, LayoutNodeState::Pane(PaneState::fallback(None))); + let pane = first_leaf_pane(&root); + let tab_id = pane::active_tab_in_pane(&pane).unwrap(); + let (window, row, sidebar_list) = { + let s = state.borrow(); + let ws = s.workspaces.iter().find(|ws| ws.id == ws_id).unwrap(); + ( + s.window.clone(), + ws.sidebar_row.clone(), + s.sidebar_list.clone(), + ) + }; + let wait = std::time::Duration::from_secs(5); + pump_until(wait, || window.is_active()); + assert!(window.is_active(), "Limux must start active"); + assert!(pane::mark_active_tab_manual_unread_in_root(&root)); + sync_workspace_unread(&state, &ws_id); + + let menu = open_context_menu(row.upcast_ref()); + menu_item(&menu, "Rename").emit_clicked(); + pump_until(wait, || find_active_rename_entry(&sidebar_list).is_some()); + let entry = find_active_rename_entry(&sidebar_list).expect("rename entry"); + assert_focus_within("rename started", &state, entry.upcast_ref()); + entry.set_text("renamed while inactive"); + + let peer = adw::ApplicationWindow::builder() + .application(&app) + .title("Focus peer") + .build(); + peer.present(); + pump_until(wait, || peer.is_active() && !window.is_active()); + assert!(peer.is_active() && !window.is_active()); + assert!(find_active_rename_entry(&sidebar_list).is_none()); + assert!(state.borrow().pending_focus_restore.is_some()); + assert!(pane::workspace_has_unread_tabs(&ws_id)); + assert_eq!( + state.borrow().active_workspace().unwrap().name, + entry.text() + ); + + window.present(); + pump_until(wait, || window.is_active()); + assert!(window.is_active()); + assert_focus_within("passive activation restores terminal focus", &state, &pane); + assert!(state.borrow().pending_focus_restore.is_none()); + assert!( + pane::workspace_has_unread_tabs(&ws_id), + "passive rename focus restoration must preserve a manual tab marker" + ); + assert!(pane::activate_tab_in_pane(&pane, &tab_id)); + assert!( + !pane::workspace_has_unread_tabs(&ws_id), + "explicit tab activation still acknowledges its marker" + ); + peer.close(); + window.close(); +} + +#[test] +#[ignore = "requires a graphical display and Ghostty resources"] +fn tab_unread_changes_preserve_independent_workspace_notifications() { + let temp = tempfile::tempdir().unwrap(); + for key in ["XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME"] { + let path = temp.path().join(key); + std::fs::create_dir_all(&path).unwrap(); + std::env::set_var(key, path); + } + let ghostty_config = temp.path().join("XDG_CONFIG_HOME/ghostty"); + std::fs::create_dir_all(&ghostty_config).unwrap(); + std::fs::write( + ghostty_config.join("config"), + "command = /bin/sh\nshell-integration = none\n", + ) + .unwrap(); + let socket = temp.path().join("limux.sock"); + std::env::set_var("LIMUX_SOCKET", &socket); + std::env::set_var("LIMUX_SOCKET_PATH", &socket); + + crate::prepare_ghostty_runtime(); + adw::init().unwrap(); + crate::terminal::init_ghostty(); + let app = adw::Application::builder() + .application_id("dev.limux.UnreadSourcesTest") + .build(); + app.register(None::<&gio::Cancellable>).unwrap(); + build_window(&app); + let state = CONTROL_STATE.with(|slot| slot.borrow().as_ref().unwrap().clone()); + let (ws_id, root) = + show_focused_workspace(&state, LayoutNodeState::Pane(PaneState::fallback(None))); + let pane = first_leaf_pane(&root); + pane::add_terminal_tab_to_pane(&pane); + let surface = pane::active_surface_summary(&pane).unwrap(); + let (_, handle) = pane::terminal_handle_for_surface(&pane, Some(&surface.surface_id)).unwrap(); + let wait = std::time::Duration::from_secs(5); + pump_until(wait, || handle.health().realized); + assert!(handle.health().realized); + select_workspace_by_index(&state, 0); + + let target = DesktopNotificationTarget { + workspace_id: ws_id.clone(), + pane_id: None, + tab_id: None, + }; + mark_workspace_unread_with_message(&state, &ws_id, "Workspace notice", false, target); + let target = DesktopNotificationTarget { + workspace_id: ws_id.clone(), + pane_id: Some(surface.pane_id), + tab_id: Some(surface.tab_id.clone()), + }; + mark_workspace_unread_with_message(&state, &ws_id, "Tab notice", false, target); + assert!(pane::workspace_has_unread_tabs(&ws_id)); + assert!(handle.send_text("exit\n")); + pump_until(wait, || pane::tab_count_in_pane(&pane) == 1); + assert_eq!(pane::tab_count_in_pane(&pane), 1, "unread terminal exited"); + let (row, notify_dot) = { + let s = state.borrow(); + let workspace = s.workspaces.iter().find(|w| w.id == ws_id).unwrap(); + assert!( + workspace.unread, + "a terminal exit must preserve the independent workspace notice" + ); + ( + workspace.sidebar_row.clone(), + workspace.indicator_unread_dot.clone(), + ) + }; + assert!(!pane::workspace_has_unread_tabs(&ws_id)); + assert!(notify_dot.get_visible()); + + let menu = open_context_menu(row.upcast_ref()); + menu_item(&menu, "Mark as Read").emit_clicked(); + pump_until(wait, || !notify_dot.get_visible()); + assert!(!notify_dot.get_visible()); + let index = state + .borrow() + .workspaces + .iter() + .position(|w| w.id == ws_id) + .unwrap(); + select_workspace_by_index(&state, index); + pane::add_terminal_tab_to_pane(&pane); + assert!(pane::mark_active_tab_manual_unread_in_root(&root)); + sync_workspace_unread(&state, &ws_id); + assert!(pane::workspace_has_unread_tabs(&ws_id)); + assert!(notify_dot.get_visible()); + assert!(!pane::clear_active_tab_unread_in_root_if_not_manual(&root)); + assert!(pane::workspace_has_unread_tabs(&ws_id)); + let menu = open_context_menu(row.upcast_ref()); + menu_item(&menu, "Mark as Read").emit_clicked(); + pump_until(wait, || !pane::workspace_has_unread_tabs(&ws_id)); + assert!(!pane::workspace_has_unread_tabs(&ws_id)); + assert!( + !notify_dot.get_visible(), + "reading a tab-only notice clears it" + ); + + let menu = open_context_menu(row.upcast_ref()); + menu_item(&menu, "Mark as Unread").emit_clicked(); + pump_until(wait, || notify_dot.get_visible()); + { + let s = state.borrow(); + let workspace = s.workspaces.iter().find(|w| w.id == ws_id).unwrap(); + assert!(workspace.manual_unread); + assert!( + !workspace.unread, + "manual and notification sources are separate" + ); + } + let surface = pane::active_surface_summary(&pane).unwrap(); + let (_, handle) = pane::terminal_handle_for_surface(&pane, Some(&surface.surface_id)).unwrap(); + pump_until(wait, || handle.health().realized); + assert!(handle.send_text("exit\n")); + pump_until(wait, || pane::tab_count_in_pane(&pane) == 1); + assert_eq!(pane::tab_count_in_pane(&pane), 1); + assert!( + state.borrow().workspaces[index].manual_unread, + "a terminal exit must preserve the independent manual workspace reminder" + ); + assert!(notify_dot.get_visible()); + let menu = open_context_menu(row.upcast_ref()); + menu_item(&menu, "Mark as Read").emit_clicked(); + pump_until(wait, || !notify_dot.get_visible()); + assert!(!notify_dot.get_visible()); + assert!(!pane::workspace_has_unread_tabs(&ws_id)); + + pane::add_terminal_tab_to_pane(&pane); + let tab_id = pane::active_tab_in_pane(&pane).unwrap(); + let pane_id = pane::active_surface_summary(&pane).unwrap().pane_id; + assert_eq!( + pane::mark_tab_unread_in_workspace(&ws_id, pane_id, &tab_id), + Some(true) + ); + sync_workspace_unread(&state, &ws_id); + assert!(notify_dot.get_visible()); + assert!(pane::close_tab_in_pane(&pane, &tab_id)); + assert!(!pane::workspace_has_unread_tabs(&ws_id)); + assert!( + !notify_dot.get_visible(), + "closing a tab-only notice clears it" + ); + + let window = state.borrow().window.clone(); + window.close(); + pump_until(wait, || !window.is_visible()); +} diff --git a/rust/limux-host-linux/src/settings_editor.rs b/rust/limux-host-linux/src/settings_editor.rs index 040883a5..2856ae03 100644 --- a/rust/limux-host-linux/src/settings_editor.rs +++ b/rust/limux-host-linux/src/settings_editor.rs @@ -6,8 +6,8 @@ use gtk4 as gtk; use libadwaita as adw; use crate::app_config::{ - AppConfig, ColorScheme, LinkOpenDestination, NotificationSound, UiScale, WindowControlsSide, - DEFAULT_UI_SCALE, MAX_TAB_ACTIVITY_SECONDS, MIN_TAB_ACTIVITY_SECONDS, + AppConfig, ColorScheme, LinkOpenDestination, NotificationSound, UiScale, UnreadColor, + WindowControlsSide, DEFAULT_UI_SCALE, MAX_TAB_ACTIVITY_SECONDS, MIN_TAB_ACTIVITY_SECONDS, }; use crate::keybind_editor; use crate::shortcut_config::{NormalizedShortcut, ResolvedShortcutConfig, ShortcutId}; @@ -100,7 +100,7 @@ const UI_SCALE_DESCRIPTORS: &[UiScaleDescriptor] = &[ pub fn ui_scale_css(config: &AppConfig) -> String { let scale = config.appearance.ui_scale.get(); - UI_SCALE_DESCRIPTORS + let mut rules: Vec = UI_SCALE_DESCRIPTORS .iter() .map(|descriptor| { format!( @@ -110,8 +110,15 @@ pub fn ui_scale_css(config: &AppConfig) -> String { descriptor.base_size * scale ) }) - .collect::>() - .join("\n") + .collect(); + let c = config.appearance.unread_color.css_color(); + rules.push(format!( + ".limux-indicator-unread-dot, .limux-notify-dot, .limux-tab-unread-dot {{ color: {c}; }}\n\ + .limux-notify-msg-unread {{ color: alpha({c}, 0.9); }}\n\ + .limux-sidebar-row-unread {{ background-color: alpha({c}, 0.14); border-left: 3px solid {c}; }}\n\ + .limux-sidebar-list row:selected .limux-sidebar-row-box.limux-sidebar-row-unread {{ background: alpha({c}, 0.18); border-left: 3px solid {c}; }}" + )); + rules.join("\n") } type OnConfigChanged = dyn Fn(&AppConfig, &AppConfig); @@ -262,6 +269,28 @@ fn build_general_page(input: &SettingsEditorInput) -> gtk::Widget { ghostty_row.set_activatable_widget(Some(&ghostty_dropdown)); group.add(&ghostty_row); + let unread_color_row = adw::ActionRow::builder() + .title("Unread indicator color") + .subtitle( + "Accent color for unread workspace rows, borders, and tab dots (distinct from blue selection)", + ) + .build(); + unread_color_row.set_title_lines(1); + unread_color_row.set_subtitle_lines(2); + let unread_color_dropdown = gtk::DropDown::from_strings(UnreadColor::labels()); + unread_color_dropdown.set_selected( + input + .config + .borrow() + .appearance + .unread_color + .dropdown_index(), + ); + unread_color_dropdown.set_valign(gtk::Align::Center); + unread_color_row.add_suffix(&unread_color_dropdown); + unread_color_row.set_activatable_widget(Some(&unread_color_dropdown)); + group.add(&unread_color_row); + let hover_row = adw::ActionRow::builder() .title("Hover terminal focus") .subtitle("Focus terminal panes when the mouse pointer enters them") @@ -502,6 +531,30 @@ fn build_general_page(input: &SettingsEditorInput) -> gtk::Widget { }); }); } + { + let config = input.config.clone(); + let on_changed = input.on_config_changed.clone(); + let syncing = Rc::new(Cell::new(false)); + unread_color_dropdown.connect_selected_notify(move |dropdown| { + if syncing.get() { + return; + } + let unread_color = UnreadColor::from_dropdown_index(dropdown.selected()); + if unread_color == config.borrow().appearance.unread_color { + return; + } + let effective = apply_config_change(&config, &*on_changed, move |c| { + c.appearance.unread_color = unread_color; + }) + .appearance + .unread_color; + if dropdown.selected() != effective.dropdown_index() { + syncing.set(true); + dropdown.set_selected(effective.dropdown_index()); + syncing.set(false); + } + }); + } { let config = input.config.clone(); let on_changed = input.on_config_changed.clone(); @@ -913,8 +966,18 @@ mod tests { timeout_spin.value_as_int(), DEFAULT_TAB_ACTIVITY_SECONDS as i32 ); + let unread_color = find_control(&page, "Unread indicator color") + .expect("settings control") + .downcast::() + .expect("dropdown"); + unread_color.set_selected(UnreadColor::Rose.dropdown_index()); + assert_eq!( + unread_color.selected(), + UnreadColor::default().dropdown_index(), + "rejected unread color was retained" + ); assert_eq!(config.borrow().interface, Default::default()); - assert_eq!(rejected_changes.get(), 5, "rollback must not save again"); + assert_eq!(rejected_changes.get(), 6, "rollback must not save again"); reject_changes.set(false); tab_activity_switch.set_active(false); diff --git a/rust/limux-host-linux/src/window.rs b/rust/limux-host-linux/src/window.rs index d786ee7d..7339d2cb 100644 --- a/rust/limux-host-linux/src/window.rs +++ b/rust/limux-host-linux/src/window.rs @@ -58,6 +58,8 @@ struct Workspace { notify_label: gtk::Label, /// Unread state for notifications without a tab target. unread: bool, + /// True when the user explicitly marked this workspace unread via the context menu. + manual_unread: bool, /// Background colour of the sidebar row, if the user picked one. color: Option, /// Last known working directory from the terminal (via OSC 7). @@ -1670,7 +1672,7 @@ const BASE_CSS: &str = r#" font-weight: 600; } .limux-indicator-unread-dot { - color: @accent_bg_color; + color: #f59e0b; font-size: 7px; margin-right: 4px; } @@ -1785,7 +1787,7 @@ const BASE_CSS: &str = r#" margin: 0; } .limux-notify-dot { - color: @accent_bg_color; + color: #f59e0b; font-size: 8px; margin-right: 6px; } @@ -1799,15 +1801,19 @@ const BASE_CSS: &str = r#" font-size: 11px; } .limux-notify-msg-unread { - color: alpha(@accent_bg_color, 0.85); + color: alpha(#f59e0b, 0.9); font-size: 11px; } .limux-sidebar-row-unread { - background-color: alpha(@accent_bg_color, 0.1); - border-left: 3px solid @accent_bg_color; + background-color: alpha(#f59e0b, 0.14); + border-left: 3px solid #f59e0b; border-radius: 8px; margin-left: 3px; } +.limux-sidebar-list row:selected .limux-sidebar-row-box.limux-sidebar-row-unread { + background: alpha(#f59e0b, 0.18); + border-left: 3px solid #f59e0b; +} .limux-sidebar-row-unread .limux-ws-name { color: @window_fg_color; font-weight: 600; @@ -2335,7 +2341,7 @@ pub fn build_window(app: &adw::Application) { .active_workspace() .map(|workspace| workspace.id.clone()); if let Some(workspace_id) = workspace_id { - clear_visible_tab_unread(&state, &workspace_id); + clear_visible_tab_unread_on_window_focus(&state, &workspace_id); } }); } @@ -2566,11 +2572,48 @@ pub fn build_window(app: &adw::Application) { } } +const GTK4_14_HOST_ENTRY_CSS: &str = r#" +.limux-host-entry { + background-color: alpha(@window_bg_color, 0.98); + color: alpha(@window_fg_color, 0.96); + border: 1px solid alpha(@window_fg_color, 0.16); + border-radius: 6px; + caret-color: currentColor; +} +.limux-host-entry:focus-within { + border-color: alpha(@accent_bg_color, 0.75); +} +.limux-host-entry text { + background-color: transparent; + color: alpha(@window_fg_color, 0.96); +} +.limux-host-entry text placeholder { + color: alpha(@window_fg_color, 0.5); +} +.limux-host-entry image { + color: alpha(@window_fg_color, 0.5); +} +"#; + +fn base_css_for_runtime_gtk() -> std::borrow::Cow<'static, str> { + if (gtk::major_version(), gtk::minor_version()) >= (4, 16) { + return std::borrow::Cow::Borrowed(BASE_CSS); + } + if let Some((_, rest)) = BASE_CSS.split_once("/* ---------- Top bar") { + std::borrow::Cow::Owned(format!( + "{GTK4_14_HOST_ENTRY_CSS}\n/* ---------- Top bar{rest}" + )) + } else { + std::borrow::Cow::Borrowed(BASE_CSS) + } +} + fn build_window_css(background_opacity: f64) -> String { let background_opacity = sanitize_background_opacity(background_opacity); let (r, g, b) = CONTENT_BACKGROUND_RGB; + let base_css = base_css_for_runtime_gtk(); format!( - "{BASE_CSS}\n.limux-content {{\n background-color: rgba({r}, {g}, {b}, {background_opacity:.3});\n}}\n" + "{base_css}\n.limux-content {{\n background-color: rgba({r}, {g}, {b}, {background_opacity:.3});\n}}\n" ) } @@ -3155,7 +3198,9 @@ fn handle_config_change( let style_manager = adw::StyleManager::default(); let system_prefers_dark = state.borrow().system_prefers_dark.get(); apply_appearance(&style_manager, system_prefers_dark, &updated.appearance); - if updated.appearance.ui_scale != previous.appearance.ui_scale { + if updated.appearance.ui_scale != previous.appearance.ui_scale + || updated.appearance.unread_color != previous.appearance.unread_color + { reload_app_css(state, updated); } if updated.appearance.show_workspace_path != previous.appearance.show_workspace_path { @@ -3171,7 +3216,9 @@ fn handle_config_change( if let Err(err) = app_config::save(updated) { state.borrow().config.borrow_mut().clone_from(previous); apply_appearance(&style_manager, system_prefers_dark, &previous.appearance); - if updated.appearance.ui_scale != previous.appearance.ui_scale { + if updated.appearance.ui_scale != previous.appearance.ui_scale + || updated.appearance.unread_color != previous.appearance.unread_color + { reload_app_css(state, previous); } if updated.appearance.show_workspace_path != previous.appearance.show_workspace_path { @@ -4448,6 +4495,22 @@ fn show_workspace_context_menu(state: &State, workspace_id: &str, row: >k::Lis menu_box.set_margin_start(4); menu_box.set_margin_end(4); + let is_unread = { + let app_state = state.borrow(); + let workspace_unread = app_state + .workspaces + .iter() + .find(|workspace| workspace.id == workspace_id) + .is_some_and(|workspace| workspace.unread || workspace.manual_unread); + workspace_unread || pane::workspace_has_unread_tabs(workspace_id) + }; + let unread_label = if is_unread { + "Mark as Read" + } else { + "Mark as Unread" + }; + let unread_btn = gtk::Button::with_label(unread_label); + unread_btn.add_css_class("flat"); let new_tab_btn = gtk::Button::with_label("New tab from workspace directory"); new_tab_btn.add_css_class("flat"); let rename_btn = gtk::Button::with_label("Rename"); @@ -4471,6 +4534,7 @@ fn show_workspace_context_menu(state: &State, workspace_id: &str, row: >k::Lis delete_btn.add_css_class("destructive-action"); menu_box.append(&new_tab_btn); + menu_box.append(&unread_btn); menu_box.append(&rename_btn); menu_box.append(&autostart_btn); @@ -4505,6 +4569,45 @@ fn show_workspace_context_menu(state: &State, workspace_id: &str, row: >k::Lis }); } + { + let state = state.clone(); + let ws_id = workspace_id.to_string(); + let pop = popover.downgrade(); + unread_btn.connect_clicked(move |_| { + if let Some(pop) = pop.upgrade() { + pop.popdown(); + } + let state = state.clone(); + let ws_id = ws_id.clone(); + glib::idle_add_local_once(move || { + let root = { + let mut app_state = state.borrow_mut(); + let Some(workspace) = app_state + .workspaces + .iter_mut() + .find(|workspace| workspace.id == ws_id) + else { + return; + }; + if is_unread { + workspace.unread = false; + workspace.manual_unread = false; + } else { + workspace.manual_unread = true; + } + workspace.root.clone() + }; + if is_unread { + pane::clear_all_tabs_unread_in_root(&root); + } else { + pane::mark_active_tab_manual_unread_in_root(&root); + } + sync_workspace_unread(&state, &ws_id); + request_session_save(&state); + }); + }); + } + { let state = state.clone(); let ws_id = workspace_id.to_string(); @@ -5145,6 +5248,7 @@ fn create_workspace_for_tab(state: &State, payload: &str) -> bool { notify_dot, notify_label, unread: false, + manual_unread: false, color: None, cwd: Rc::new(RefCell::new(seed.cwd.clone())), folder_path: seed.folder_path.clone(), @@ -5211,6 +5315,8 @@ fn install_workspace_row_interactions( if n_press == 2 { gesture.set_state(gtk::EventSequenceState::Claimed); begin_workspace_inline_rename(&state, &workspace_id); + } else if n_press == 1 { + clear_visible_tab_unread(&state, &workspace_id); } }); } @@ -6663,6 +6769,7 @@ fn add_workspace_with_initial_command( notify_dot, notify_label, unread: false, + manual_unread: false, color: workspace.color, cwd, folder_path: workspace.folder_path.clone(), @@ -7112,7 +7219,7 @@ fn schedule_focus_restore_if_idle( { return; } - if pane::activate_tab_in_pane(&pane_widget, &target.tab_id) { + if pane::focus_active_tab_in_pane(&pane_widget) { return; } } @@ -8316,7 +8423,7 @@ fn sync_workspace_unread(state: &State, ws_id: &str) { let Some(workspace) = s.workspaces.iter().find(|workspace| workspace.id == ws_id) else { return; }; - workspace.unread + workspace.unread || workspace.manual_unread }; let tab_unread = pane::workspace_has_unread_tabs(ws_id); let s = state.borrow(); @@ -8339,12 +8446,33 @@ fn clear_visible_tab_unread(state: &State, ws_id: &str) { return; }; workspace.unread = false; + workspace.manual_unread = false; workspace.root.clone() }; pane::clear_active_tab_unread_in_root(&root); sync_workspace_unread(state, ws_id); } +fn clear_visible_tab_unread_on_window_focus(state: &State, ws_id: &str) { + let root = { + let mut s = state.borrow_mut(); + if !s.window.is_active() { + return; + } + let Some(workspace) = s + .workspaces + .iter_mut() + .find(|workspace| workspace.id == ws_id) + else { + return; + }; + workspace.unread = false; + workspace.root.clone() + }; + pane::clear_active_tab_unread_in_root_if_not_manual(&root); + sync_workspace_unread(state, ws_id); +} + fn mark_workspace_unread( state: &State, ws_id: &str, diff --git a/scripts/xvfb-smoke-test.sh b/scripts/xvfb-smoke-test.sh index 9ab9b209..40239577 100755 --- a/scripts/xvfb-smoke-test.sh +++ b/scripts/xvfb-smoke-test.sh @@ -408,6 +408,7 @@ for regression in \ terminal::tests::detach_after_repaint_waits_for_a_frame_without_the_widget \ window::pane_create_tests::pane_create_replies_once_the_new_pane_can_be_targeted \ window::pane_close_tests::closed_tabs_panes_and_workspaces_free_their_widgets \ + window::pane_close_tests::tab_unread_changes_preserve_independent_workspace_notifications \ window::session_save_tests::close_during_pending_save_persists_the_latest_snapshot \ window::sidebar_group_tests::empty_saved_session_restores_group_headers_and_metadata \ window::pane_close_tests::zoom_and_split_within_a_frame_of_a_close_skip_the_closed_pane \ @@ -423,9 +424,8 @@ for regression in \ -- --exact --ignored --test-threads=1 --nocapture done -# Weston headless has no active input seat: GtkWindow::is_active() is false -# when this test commits its editor, so it cannot validate the active-window -# focus contract. Keep the other GTK regressions on Wayland and run this one +# Weston headless has no active input seat, so it cannot validate active-window +# focus or keyboard delivery. Keep the other GTK regressions on Wayland and run these # under isolated X11, where the window can become active. command -v xvfb-run >/dev/null || { echo "FAIL: xvfb-run is required for the inline rename focus regression" @@ -437,6 +437,7 @@ command -v xdotool >/dev/null || { } for regression in \ window::pane_close_tests::inline_rename_commit_returns_the_focus_to_the_workspace \ + window::pane_close_tests::passive_rename_focus_restore_preserves_manual_tab_unread \ pane::ssh_reconnect_tests::reconnect_preserves_neighbor_keyboard_focus_and_restores_its_own; do echo "== regression: $regression under isolated X11 ==" timeout -k 5s 30s xvfb-run -a -s '-screen 0 1440x1000x24 -nolisten tcp' \