aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-28 01:05:33 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-28 01:05:33 +0300
commitb6308ef3bec7ebd46ac830b3d36910171e94edf2 (patch)
tree634866d39a3c98f0973d0f08c3da5018d051e337 /src
parent5a61ae2f9d89320c2e91d58fc3646b5a6a88f44a (diff)
feat: notify active panes when terminal is unfocused
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs47
-rw-r--r--src/app/api.rs17
-rw-r--r--src/app/mod.rs5
-rw-r--r--src/app/runtime.rs8
-rw-r--r--src/app/state.rs4
-rw-r--r--src/client/mod.rs7
-rw-r--r--src/main.rs8
-rw-r--r--src/raw_input.rs19
-rw-r--r--src/server/headless.rs209
9 files changed, 296 insertions, 28 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 210b6ccf..83e9bec6 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -15,8 +15,15 @@ fn is_background_completion_transition(prev_state: AgentState, new_state: AgentS
&& matches!(prev_state, AgentState::Working | AgentState::Blocked)
}
-pub fn notification_sound_for_state_change(
+pub fn active_tab_suppresses_notifications(
is_active_tab: bool,
+ outer_terminal_focus: Option<bool>,
+) -> bool {
+ is_active_tab && outer_terminal_focus != Some(false)
+}
+
+pub fn notification_sound_for_state_change(
+ suppress_active_tab_notifications: bool,
prev_state: AgentState,
new_state: AgentState,
) -> Option<crate::sound::Sound> {
@@ -27,7 +34,8 @@ pub fn notification_sound_for_state_change(
match new_state {
AgentState::Blocked => Some(crate::sound::Sound::Request),
AgentState::Idle
- if is_background_completion_transition(prev_state, new_state) && !is_active_tab =>
+ if is_background_completion_transition(prev_state, new_state)
+ && !suppress_active_tab_notifications =>
{
Some(crate::sound::Sound::Done)
}
@@ -36,11 +44,11 @@ pub fn notification_sound_for_state_change(
}
pub fn notification_toast_for_state_change(
- is_active_tab: bool,
+ suppress_active_tab_notifications: bool,
prev_state: AgentState,
new_state: AgentState,
) -> Option<ToastKind> {
- if is_active_tab || new_state == prev_state {
+ if suppress_active_tab_notifications || new_state == prev_state {
return None;
}
@@ -573,6 +581,8 @@ impl AppState {
change: &EffectiveStateChange,
) {
let is_active_tab = self.pane_is_in_active_tab(ws_idx, pane_id);
+ let suppress_active_tab_notifications =
+ active_tab_suppresses_notifications(is_active_tab, self.outer_terminal_focus);
let Some(pane) = self.workspaces[ws_idx]
.tabs
.iter_mut()
@@ -582,14 +592,14 @@ impl AppState {
};
if is_background_completion_transition(change.previous_state, change.state)
- && !is_active_tab
+ && !suppress_active_tab_notifications
{
pane.seen = false;
}
if self.local_sound_playback && self.sound.allows(change.known_agent) {
if let Some(sound) = notification_sound_for_state_change(
- is_active_tab,
+ suppress_active_tab_notifications,
change.previous_state,
change.state,
) {
@@ -1066,6 +1076,31 @@ mod tests {
}
#[test]
+ fn active_workspace_active_tab_keeps_herdr_toast_suppressed_when_outer_terminal_is_unfocused() {
+ let mut state = app_with_workspaces(&["active"]);
+ state.active = Some(0);
+ state.outer_terminal_focus = Some(false);
+ state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+ let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
+
+ state.handle_app_event(AppEvent::StateChanged {
+ pane_id,
+ agent: Some(Agent::Pi),
+ state: AgentState::Blocked,
+ });
+
+ assert!(state.toast.is_none());
+ }
+
+ #[test]
+ fn active_tab_suppression_preserves_unknown_focus_behavior() {
+ assert!(active_tab_suppresses_notifications(true, None));
+ assert!(active_tab_suppresses_notifications(true, Some(true)));
+ assert!(!active_tab_suppresses_notifications(true, Some(false)));
+ assert!(!active_tab_suppresses_notifications(false, None));
+ }
+
+ #[test]
fn update_ready_sets_manual_update_toast() {
let mut state = AppState::test_new();
state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
diff --git a/src/app/api.rs b/src/app/api.rs
index d7893d6d..5cfa3409 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -70,10 +70,12 @@ impl App {
self.restore_overlay_after_exit(overlay);
}
- if matches!(
- self.state.toast_config.delivery,
- crate::config::ToastDelivery::Terminal
- ) {
+ if self.local_terminal_notifications
+ && matches!(
+ self.state.toast_config.delivery,
+ crate::config::ToastDelivery::Terminal
+ )
+ {
if let Some(version) = update_ready_version {
let _ = crate::terminal_notify::show_notification(
&format!("v{version} available"),
@@ -84,8 +86,13 @@ impl App {
let is_active_tab = self
.state
.pane_is_in_active_tab(update.ws_idx, update.pane_id);
+ let suppress_active_tab_notifications =
+ crate::app::actions::active_tab_suppresses_notifications(
+ is_active_tab,
+ self.state.outer_terminal_focus,
+ );
let Some(kind) = crate::app::actions::notification_toast_for_state_change(
- is_active_tab,
+ suppress_active_tab_notifications,
update.previous_state,
update.state,
) else {
diff --git a/src/app/mod.rs b/src/app/mod.rs
index 26090ebf..3d588403 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -77,6 +77,7 @@ pub struct App {
pub render_notify: Arc<Notify>,
pub render_dirty: Arc<AtomicBool>,
pub(crate) overlay_panes: HashMap<crate::layout::PaneId, OverlayPaneState>,
+ pub(crate) local_terminal_notifications: bool,
}
pub(crate) enum LoopEvent {
@@ -315,6 +316,7 @@ impl App {
update_dismissed: false,
config_diagnostic,
toast: None,
+ outer_terminal_focus: None,
prefix_code,
prefix_mods,
default_sidebar_width: config.ui.sidebar_width,
@@ -395,6 +397,7 @@ impl App {
render_notify,
render_dirty,
overlay_panes: HashMap::new(),
+ local_terminal_notifications: true,
}
}
@@ -761,6 +764,8 @@ impl App {
}
}
}
+ crate::raw_input::RawInputEvent::OuterFocusGained
+ | crate::raw_input::RawInputEvent::OuterFocusLost => {}
crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
if apply_host_terminal_theme {
self.update_host_terminal_theme(kind, color);
diff --git a/src/app/runtime.rs b/src/app/runtime.rs
index 739a3670..094b26f1 100644
--- a/src/app/runtime.rs
+++ b/src/app/runtime.rs
@@ -89,6 +89,14 @@ impl App {
self.handle_mouse(mouse);
true
}
+ crate::raw_input::RawInputEvent::OuterFocusGained => {
+ self.state.outer_terminal_focus = Some(true);
+ false
+ }
+ crate::raw_input::RawInputEvent::OuterFocusLost => {
+ self.state.outer_terminal_focus = Some(false);
+ false
+ }
crate::raw_input::RawInputEvent::HostDefaultColor { kind, color } => {
self.update_host_terminal_theme(kind, color)
}
diff --git a/src/app/state.rs b/src/app/state.rs
index 971b1476..facb5187 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -631,6 +631,9 @@ pub struct AppState {
pub update_dismissed: bool,
pub config_diagnostic: Option<String>,
pub toast: Option<ToastNotification>,
+ /// Last reported focus state for the outer terminal hosting herdr.
+ /// None means unsupported or not yet reported, which preserves active-pane suppression.
+ pub outer_terminal_focus: Option<bool>,
// Config
pub prefix_code: KeyCode,
pub prefix_mods: KeyModifiers,
@@ -765,6 +768,7 @@ impl AppState {
update_dismissed: false,
config_diagnostic: None,
toast: None,
+ outer_terminal_focus: None,
prefix_code: KeyCode::Char('b'),
prefix_mods: KeyModifiers::CONTROL,
default_sidebar_width: 26,
diff --git a/src/client/mod.rs b/src/client/mod.rs
index 19d07bfb..f95d0fd4 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -22,8 +22,9 @@ use std::sync::Arc;
use std::time::Duration;
use crossterm::event::{
- DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
- KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
+ EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
+ PushKeyboardEnhancementFlags,
};
use crossterm::execute;
use tracing::{debug, info, warn};
@@ -136,6 +137,7 @@ fn setup_terminal() -> io::Result<TerminalGuard> {
io::stdout(),
EnableMouseCapture,
EnableBracketedPaste,
+ EnableFocusChange,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
@@ -175,6 +177,7 @@ fn restore_terminal_state(in_tmux: bool) {
let _ = execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
+ DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
);
diff --git a/src/main.rs b/src/main.rs
index 1d8439b9..dbc77060 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,8 +1,9 @@
use std::io;
use crossterm::event::{
- DisableBracketedPaste, DisableMouseCapture, EnableBracketedPaste, EnableMouseCapture,
- KeyboardEnhancementFlags, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
+ DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
+ EnableFocusChange, EnableMouseCapture, KeyboardEnhancementFlags, PopKeyboardEnhancementFlags,
+ PushKeyboardEnhancementFlags,
};
use crossterm::execute;
@@ -342,6 +343,7 @@ fn main() -> io::Result<()> {
let _ = execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
+ DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
);
@@ -368,6 +370,7 @@ fn main() -> io::Result<()> {
io::stdout(),
EnableMouseCapture,
EnableBracketedPaste,
+ EnableFocusChange,
PushKeyboardEnhancementFlags(
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
@@ -406,6 +409,7 @@ fn main() -> io::Result<()> {
execute!(
io::stdout(),
PopKeyboardEnhancementFlags,
+ DisableFocusChange,
DisableBracketedPaste,
DisableMouseCapture
)?;
diff --git a/src/raw_input.rs b/src/raw_input.rs
index a98cf25d..c2a412b2 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -125,6 +125,8 @@ pub enum RawInputEvent {
Key(TerminalKey),
Paste(String),
Mouse(MouseEvent),
+ OuterFocusGained,
+ OuterFocusLost,
HostDefaultColor {
kind: DefaultColorKind,
color: RgbColor,
@@ -297,6 +299,12 @@ fn extract_one_event(buffer: &[u8]) -> Option<(RawInputEvent, usize)> {
return Some((RawInputEvent::HostDefaultColor { kind, color }, seq_len));
}
+ match seq {
+ "\x1b[I" => return Some((RawInputEvent::OuterFocusGained, seq_len)),
+ "\x1b[O" => return Some((RawInputEvent::OuterFocusLost, seq_len)),
+ _ => {}
+ }
+
if let Some(mouse) = parse_sgr_mouse(seq) {
return Some((RawInputEvent::Mouse(mouse), seq_len));
}
@@ -604,6 +612,17 @@ mod tests {
}
#[test]
+ fn parses_outer_focus_events() {
+ let (event, consumed) = extract_one_event(b"\x1b[I").unwrap();
+ assert_eq!(consumed, 3);
+ assert!(matches!(event, RawInputEvent::OuterFocusGained));
+
+ let (event, consumed) = extract_one_event(b"\x1b[O").unwrap();
+ assert_eq!(consumed, 3);
+ assert!(matches!(event, RawInputEvent::OuterFocusLost));
+ }
+
+ #[test]
fn parses_xterm_alt_up_arrow() {
let (RawInputEvent::Key(key), consumed) = extract_one_event(b"\x1b[1;3A").unwrap() else {
panic!("expected key");
diff --git a/src/server/headless.rs b/src/server/headless.rs
index cffffe6a..5f9aad2c 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -117,12 +117,15 @@ fn toast_event_text(kind: app::state::ToastKind) -> &'static str {
fn toast_message_from_state_change(
state: &AppState,
pane_id: PaneId,
- is_active_tab: bool,
+ suppress_active_tab_notifications: bool,
prev_state: AgentState,
new_state: AgentState,
) -> Option<String> {
- let kind =
- app::actions::notification_toast_for_state_change(is_active_tab, prev_state, new_state)?;
+ let kind = app::actions::notification_toast_for_state_change(
+ suppress_active_tab_notifications,
+ prev_state,
+ new_state,
+ )?;
state
.workspaces
@@ -211,6 +214,8 @@ struct ClientConnection {
terminal_size: (u16, u16),
/// Last known host terminal default colors for this client.
host_terminal_theme: crate::terminal_theme::TerminalTheme,
+ /// Last reported focus state for this client's outer terminal.
+ outer_terminal_focus: Option<bool>,
/// Monotonic activity stamp used to choose the fallback foreground client.
last_activity: u64,
/// Last frame sent to this client. Used to skip identical frame sends.
@@ -615,20 +620,35 @@ impl HeadlessServer {
fn sync_foreground_client_state(&mut self) {
let Some(client_id) = self.foreground_client_id else {
self.effective_size = (MIN_COLS, MIN_ROWS);
+ self.app.state.outer_terminal_focus = None;
return;
};
let Some(client) = self.clients.get(&client_id) else {
self.foreground_client_id = None;
self.effective_size = (MIN_COLS, MIN_ROWS);
+ self.app.state.outer_terminal_focus = None;
return;
};
self.effective_size = client.terminal_size;
+ self.app.state.outer_terminal_focus = client.outer_terminal_focus;
if !client.host_terminal_theme.is_empty() {
self.app.set_host_terminal_theme(client.host_terminal_theme);
}
}
+ fn foreground_client_outer_focus(&self) -> Option<bool> {
+ let client_id = self.foreground_client_id?;
+ self.clients.get(&client_id)?.outer_terminal_focus
+ }
+
+ fn active_tab_suppresses_notifications(&self, is_active_tab: bool) -> bool {
+ crate::app::actions::active_tab_suppresses_notifications(
+ is_active_tab,
+ self.foreground_client_outer_focus(),
+ )
+ }
+
fn promote_client_to_foreground(&mut self, client_id: u64) -> bool {
let stamp = self.allocate_activity_stamp();
let Some(client) = self.clients.get_mut(&client_id) else {
@@ -692,6 +712,32 @@ impl HeadlessServer {
}
}
+ fn update_client_outer_focus_from_events(
+ &mut self,
+ client_id: u64,
+ events: &[crate::raw_input::RawInputEvent],
+ ) {
+ let next_focus = events
+ .iter()
+ .filter_map(|event| match event {
+ crate::raw_input::RawInputEvent::OuterFocusGained => Some(true),
+ crate::raw_input::RawInputEvent::OuterFocusLost => Some(false),
+ _ => None,
+ })
+ .last();
+
+ let Some(next_focus) = next_focus else {
+ return;
+ };
+ let Some(client) = self.clients.get_mut(&client_id) else {
+ return;
+ };
+ client.outer_terminal_focus = Some(next_focus);
+ if self.foreground_client_id == Some(client_id) {
+ self.app.state.outer_terminal_focus = Some(next_focus);
+ }
+ }
+
fn events_include_interaction(events: &[crate::raw_input::RawInputEvent]) -> bool {
events.iter().any(|event| {
matches!(
@@ -699,6 +745,7 @@ impl HeadlessServer {
crate::raw_input::RawInputEvent::Key(_)
| crate::raw_input::RawInputEvent::Mouse(_)
| crate::raw_input::RawInputEvent::Paste(_)
+ | crate::raw_input::RawInputEvent::OuterFocusGained
)
})
}
@@ -804,6 +851,7 @@ impl HeadlessServer {
// Handle the state change (updates pane state, sets toast on AppState).
// Headless mode disables local sound playback separately from the
// sound policy so reloads can keep server-side notification policy live.
+ self.sync_foreground_client_state();
self.app.handle_internal_event(ev);
// Forward sound notification to clients when server-side sound policy allows it.
@@ -817,9 +865,12 @@ impl HeadlessServer {
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
});
+ let suppress_active_tab_notifications =
+ self.active_tab_suppresses_notifications(is_active_tab);
+
if self.app.state.sound.allows(agent_val) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
- is_active_tab,
+ suppress_active_tab_notifications,
prev_state,
state_val,
) {
@@ -846,7 +897,7 @@ impl HeadlessServer {
toast_message_from_state_change(
&self.app.state,
pane_id_val,
- is_active_tab,
+ suppress_active_tab_notifications,
prev_state,
state_val,
)
@@ -897,6 +948,7 @@ impl HeadlessServer {
})
.unwrap_or(crate::detect::AgentState::Unknown);
+ self.sync_foreground_client_state();
self.app.handle_internal_event(ev);
// Forward sound notification based on hook state transition when
@@ -913,9 +965,12 @@ impl HeadlessServer {
.is_some_and(|tab_idx| ws.active_tab_index() == tab_idx)
});
+ let suppress_active_tab_notifications =
+ self.active_tab_suppresses_notifications(is_active_tab);
+
if self.app.state.sound.allows(agent_val) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
- is_active_tab,
+ suppress_active_tab_notifications,
prev_hook_state,
hook_state_val,
) {
@@ -942,7 +997,7 @@ impl HeadlessServer {
toast_message_from_state_change(
&self.app.state,
pane_id_val,
- is_active_tab,
+ suppress_active_tab_notifications,
prev_hook_state,
hook_state_val,
)
@@ -1123,6 +1178,7 @@ impl HeadlessServer {
ClientConnection {
terminal_size: (cols, rows),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity,
last_frame: None,
writer: Some(writer),
@@ -1143,6 +1199,7 @@ impl HeadlessServer {
client.last_frame = None;
}
let events = crate::raw_input::parse_raw_input_bytes_sync(&data);
+ self.update_client_outer_focus_from_events(client_id, &events);
let interaction = Self::events_include_interaction(&events);
let foreground_changed = if interaction {
self.promote_client_to_foreground(client_id)
@@ -1301,6 +1358,7 @@ impl HeadlessServer {
})
.collect();
+ self.sync_foreground_client_state();
let response = self.app.handle_api_request(msg.request);
let _ = msg.respond_to.send(response);
@@ -1370,6 +1428,8 @@ impl HeadlessServer {
}
let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id);
+ let suppress_active_tab_notifications =
+ self.active_tab_suppresses_notifications(is_active_tab);
// Get the known agent for sound settings. Unknown custom labels
// fall back to None so clients use the generic sound behavior.
@@ -1390,7 +1450,7 @@ impl HeadlessServer {
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
{
if let Some(kind) = crate::app::actions::notification_toast_for_state_change(
- is_active_tab,
+ suppress_active_tab_notifications,
prev_state,
new_state,
) {
@@ -1422,7 +1482,7 @@ impl HeadlessServer {
// Clients still decide locally whether they can execute the side effect.
if self.app.state.sound.allows(agent) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
- is_active_tab,
+ suppress_active_tab_notifications,
prev_state,
new_state,
) {
@@ -1965,10 +2025,11 @@ pub fn run_server() -> io::Result<()> {
event_hub,
);
- // The server runs headless — disable local sound playback.
- // Sound notifications are forwarded to connected clients as
- // ServerMessage::Notify instead of played locally.
+ // The server runs headless — disable local notification side effects.
+ // Sound and terminal notifications are forwarded to connected clients
+ // as ServerMessage::Notify instead of emitted by the server process.
app.state.local_sound_playback = false;
+ app.local_terminal_notifications = false;
// Create the headless server.
let mut server = match HeadlessServer::new(app) {
@@ -2013,7 +2074,10 @@ mod tests {
fn test_headless_server() -> HeadlessServer {
let config = crate::config::Config::default();
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
- let app = crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default());
+ let mut app =
+ crate::app::App::new(&config, true, None, None, api_rx, api::EventHub::default());
+ app.state.local_sound_playback = false;
+ app.local_terminal_notifications = false;
let dir = std::env::temp_dir().join(format!(
"hh-{}-{}",
@@ -2255,6 +2319,7 @@ mod tests {
b: 0x33,
}),
},
+ outer_terminal_focus: None,
last_activity: 1,
last_frame: None,
writer: None,
@@ -2276,6 +2341,7 @@ mod tests {
b: 0xff,
}),
},
+ outer_terminal_focus: None,
last_activity: 2,
last_frame: None,
writer: None,
@@ -2308,6 +2374,114 @@ mod tests {
}
#[test]
+ fn focus_lost_updates_client_without_promoting_foreground() {
+ let mut server = test_headless_server();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
+ last_activity: 1,
+ last_frame: None,
+ writer: None,
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: Some(true),
+ last_activity: 2,
+ last_frame: None,
+ writer: None,
+ },
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ let changed = server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\x1b[O".to_vec(),
+ });
+
+ assert!(!changed);
+ assert_eq!(server.foreground_client_id, Some(2));
+ assert_eq!(server.clients[&1].outer_terminal_focus, Some(false));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(true));
+ }
+
+ #[test]
+ fn focus_gained_promotes_client_to_foreground() {
+ let mut server = test_headless_server();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
+ last_activity: 1,
+ last_frame: None,
+ writer: None,
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: Some(true),
+ last_activity: 2,
+ last_frame: None,
+ writer: None,
+ },
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ let changed = server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\x1b[I".to_vec(),
+ });
+
+ assert!(changed);
+ assert_eq!(server.foreground_client_id, Some(1));
+ assert_eq!(server.clients[&1].outer_terminal_focus, Some(true));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(true));
+ }
+
+ #[test]
+ fn foreground_client_focus_event_updates_app_focus_state() {
+ let mut server = test_headless_server();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: Some(true),
+ last_activity: 1,
+ last_frame: None,
+ writer: None,
+ },
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ let changed = server.handle_server_event(ServerEvent::ClientInput {
+ client_id: 1,
+ data: b"\x1b[O".to_vec(),
+ });
+
+ assert!(!changed);
+ assert_eq!(server.clients[&1].outer_terminal_focus, Some(false));
+ assert_eq!(server.app.state.outer_terminal_focus, Some(false));
+ }
+
+ #[test]
fn render_and_stream_uses_each_client_terminal_size() {
let mut server = test_headless_server();
server.app.state.workspaces = vec![crate::workspace::Workspace::test_new("test")];
@@ -2323,6 +2497,7 @@ mod tests {
ClientConnection {
terminal_size: (120, 40),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 1,
last_frame: None,
writer: Some(desktop_tx),
@@ -2333,6 +2508,7 @@ mod tests {
ClientConnection {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 2,
last_frame: None,
writer: Some(phone_tx),
@@ -2366,6 +2542,7 @@ mod tests {
ClientConnection {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 1,
last_frame: None,
writer: Some(client_tx),
@@ -2396,6 +2573,7 @@ mod tests {
ClientConnection {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 1,
last_frame: None,
writer: Some(client_tx),
@@ -2427,6 +2605,7 @@ mod tests {
ClientConnection {
terminal_size: (120, 40),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 1,
last_frame: None,
writer: Some(background_tx),
@@ -2437,6 +2616,7 @@ mod tests {
ClientConnection {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 2,
last_frame: None,
writer: Some(foreground_tx),
@@ -2477,6 +2657,7 @@ mod tests {
ClientConnection {
terminal_size: (120, 40),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 1,
last_frame: None,
writer: Some(background_tx),
@@ -2487,6 +2668,7 @@ mod tests {
ClientConnection {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 2,
last_frame: None,
writer: Some(foreground_tx),
@@ -2529,6 +2711,7 @@ mod tests {
ClientConnection {
terminal_size: (80, 24),
host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ outer_terminal_focus: None,
last_activity: 1,
last_frame: None,
writer: Some(client_tx),