use crate::FocusMode;
use crate::activate;
use crate::util::DEFAULT_TIMEOUT;
use std::collections::HashMap;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::thread;
#[derive(Debug, Clone)]
struct WindowMatch {
tab_id: i64,
window_id: i64,
}
pub fn try_focus_by_pid(target_pid: i32, _tty_device: &str, mode: FocusMode) -> bool {
let socket = get_socket();
let Some(os_windows) = query_kitty_ls(socket.as_deref()) else {
log_remote_control_warning();
activate::window("kitty");
return true;
};
let parent_map = prock::build_parent_map();
let Some(window_match) = find_window_by_pid(&os_windows, target_pid, &parent_map) else {
return false;
};
if !focus_tab_and_window(socket.as_deref(), &window_match) {
return false;
}
bring_to_front(mode)
}
pub fn try_focus(tty_device: &str, mode: FocusMode) -> bool {
let socket = get_socket();
let Some(os_windows) = query_kitty_ls(socket.as_deref()) else {
log_remote_control_warning();
activate::window("kitty");
return true;
};
let Some(window_match) = find_window_by_tty(&os_windows, tty_device) else {
return false;
};
if !focus_tab_and_window(socket.as_deref(), &window_match) {
return false;
}
bring_to_front(mode)
}
fn get_socket() -> Option<String> {
std::env::var("KITTY_LISTEN_ON").ok()
}
static WARNED_REMOTE_CONTROL: AtomicBool = AtomicBool::new(false);
#[expect(clippy::print_stderr, reason = "intentional warning message to stderr")]
fn log_remote_control_warning() {
if WARNED_REMOTE_CONTROL.swap(true, Ordering::Relaxed) {
return;
}
eprintln!(
"focal: Kitty remote control is not available. Falling back to app activation.\n\
For precise tab/pane focus, add to kitty.conf:\n \
allow_remote_control socket-only\n \
listen_on unix:/tmp/kitty-{{kitty_pid}}.sock"
);
}
fn query_kitty_ls(socket: Option<&str>) -> Option<Vec<serde_json::Value>> {
let output = run_kitty_command_with_timeout(socket, &["ls"])?;
if !output.status.success() {
return None;
}
serde_json::from_slice(&output.stdout).ok()
}
fn run_kitty_command_with_timeout(socket: Option<&str>, args: &[&str]) -> Option<Output> {
let socket_owned = socket.map(String::from);
let args_owned: Vec<String> = args.iter().map(|s| (*s).to_string()).collect();
let (tx, rx) = mpsc::channel();
thread::spawn(move || {
let mut cmd = Command::new("kitty");
cmd.arg("@");
if let Some(ref socket) = socket_owned {
cmd.args(["--to", socket]);
}
cmd.args(&args_owned);
let result = cmd.output();
let _ = tx.send(result);
});
match rx.recv_timeout(DEFAULT_TIMEOUT) {
Ok(Ok(output)) => Some(output),
_ => None,
}
}
fn run_kitty_command(socket: Option<&str>, args: &[&str]) -> bool {
let mut cmd = Command::new("kitty");
cmd.arg("@");
if let Some(socket) = socket {
cmd.args(["--to", socket]);
}
cmd.args(args);
cmd.status().map(|s| s.success()).unwrap_or(false)
}
fn find_window_by_pid<S: std::hash::BuildHasher>(
os_windows: &[serde_json::Value],
target_pid: i32,
parent_map: &HashMap<i32, i32, S>,
) -> Option<WindowMatch> {
for os_window in os_windows {
let Some(tabs) = os_window.get("tabs").and_then(|v| v.as_array()) else {
continue;
};
for tab in tabs {
let Some(tab_id) = tab.get("id").and_then(|v| v.as_i64()) else {
continue;
};
let Some(windows) = tab.get("windows").and_then(|v| v.as_array()) else {
continue;
};
for window in windows {
let Some(window_id) = window.get("id").and_then(|v| v.as_i64()) else {
continue;
};
if let Some(procs) = window
.get("foreground_processes")
.and_then(|v| v.as_array())
{
for proc in procs {
if let Some(fg_pid) = proc.get("pid").and_then(|v| v.as_i64()) {
let fg_pid = fg_pid as i32;
if prock::is_descendant_of(target_pid, fg_pid, parent_map) {
return Some(WindowMatch { tab_id, window_id });
}
}
}
}
}
}
}
None
}
fn find_window_by_tty(os_windows: &[serde_json::Value], tty_device: &str) -> Option<WindowMatch> {
let tty_full = format!("/dev/{tty_device}");
for os_window in os_windows {
let Some(tabs) = os_window.get("tabs").and_then(|v| v.as_array()) else {
continue;
};
for tab in tabs {
let Some(tab_id) = tab.get("id").and_then(|v| v.as_i64()) else {
continue;
};
let Some(windows) = tab.get("windows").and_then(|v| v.as_array()) else {
continue;
};
for window in windows {
let Some(window_id) = window.get("id").and_then(|v| v.as_i64()) else {
continue;
};
if let Some(procs) = window
.get("foreground_processes")
.and_then(|v| v.as_array())
{
for proc in procs {
if let Some(cmdline) = proc.get("cmdline").and_then(|v| v.as_array()) {
for arg in cmdline {
if let Some(s) = arg.as_str()
&& (s == tty_full || s.ends_with(tty_device))
{
return Some(WindowMatch { tab_id, window_id });
}
}
}
if let Some(cwd) = proc.get("cwd").and_then(|v| v.as_str())
&& (cwd == tty_full || cwd.ends_with(tty_device))
{
return Some(WindowMatch { tab_id, window_id });
}
}
}
}
}
}
None
}
fn focus_tab_and_window(socket: Option<&str>, window_match: &WindowMatch) -> bool {
if !run_kitty_command(
socket,
&["focus-tab", "-m", &format!("id:{}", window_match.tab_id)],
) {
return false;
}
run_kitty_command(
socket,
&[
"focus-window",
"-m",
&format!("id:{}", window_match.window_id),
],
)
}
fn bring_to_front(mode: FocusMode) -> bool {
match mode {
FocusMode::SingleWindow => bring_to_front_single_window(),
FocusMode::ActivateApp => {
activate::window("kitty");
true
}
}
}
#[cfg(target_os = "macos")]
fn bring_to_front_single_window() -> bool {
super::jxa::raise_front_window("kitty")
}
#[cfg(not(target_os = "macos"))]
fn bring_to_front_single_window() -> bool {
activate::window("kitty");
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore = "steals window focus when Kitty is running without remote control"]
fn test_try_focus_nonexistent_tty() {
let result = try_focus("ttys999999", FocusMode::SingleWindow);
let _ = result;
}
#[test]
fn test_find_window_by_pid_direct_match() {
let json = r#"[
{
"id": 1,
"tabs": [
{
"id": 10,
"windows": [
{
"id": 100,
"foreground_processes": [
{"pid": 1234, "cmdline": ["/bin/zsh"], "cwd": "/Users/test"}
]
}
]
}
]
}
]"#;
let os_windows: Vec<serde_json::Value> = serde_json::from_str(json).unwrap();
let mut parent_map = HashMap::new();
parent_map.insert(1234, 1);
let result = find_window_by_pid(&os_windows, 1234, &parent_map);
assert!(result.is_some());
let m = result.unwrap();
assert_eq!(m.tab_id, 10);
assert_eq!(m.window_id, 100);
}
#[test]
fn test_find_window_by_pid_descendant() {
let json = r#"[
{
"id": 1,
"tabs": [
{
"id": 10,
"windows": [
{
"id": 100,
"foreground_processes": [
{"pid": 1000, "cmdline": ["/bin/zsh"], "cwd": "/Users/test"}
]
}
]
}
]
}
]"#;
let os_windows: Vec<serde_json::Value> = serde_json::from_str(json).unwrap();
let mut parent_map = HashMap::new();
parent_map.insert(1234, 1100);
parent_map.insert(1100, 1000);
parent_map.insert(1000, 1);
let result = find_window_by_pid(&os_windows, 1234, &parent_map);
assert!(result.is_some());
let m = result.unwrap();
assert_eq!(m.tab_id, 10);
assert_eq!(m.window_id, 100);
}
#[test]
fn test_find_window_by_pid_no_match() {
let json = r#"[
{
"id": 1,
"tabs": [
{
"id": 10,
"windows": [
{
"id": 100,
"foreground_processes": [
{"pid": 1000, "cmdline": ["/bin/zsh"], "cwd": "/Users/test"}
]
}
]
}
]
}
]"#;
let os_windows: Vec<serde_json::Value> = serde_json::from_str(json).unwrap();
let mut parent_map = HashMap::new();
parent_map.insert(9999, 5000);
parent_map.insert(5000, 1);
let result = find_window_by_pid(&os_windows, 9999, &parent_map);
assert!(result.is_none());
}
#[test]
fn test_find_window_by_tty() {
let json = r#"[
{
"id": 1,
"tabs": [
{
"id": 10,
"windows": [
{
"id": 100,
"foreground_processes": [
{
"pid": 1234,
"cmdline": ["/bin/zsh", "-l"],
"cwd": "/dev/ttys003"
}
]
}
]
}
]
}
]"#;
let os_windows: Vec<serde_json::Value> = serde_json::from_str(json).unwrap();
let result = find_window_by_tty(&os_windows, "ttys003");
assert!(result.is_some());
let m = result.unwrap();
assert_eq!(m.tab_id, 10);
assert_eq!(m.window_id, 100);
}
#[test]
fn test_find_window_by_tty_no_match() {
let json = r#"[
{
"id": 1,
"tabs": [
{
"id": 10,
"windows": [
{
"id": 100,
"foreground_processes": [
{
"pid": 1234,
"cmdline": ["/bin/zsh"],
"cwd": "/Users/test"
}
]
}
]
}
]
}
]"#;
let os_windows: Vec<serde_json::Value> = serde_json::from_str(json).unwrap();
let result = find_window_by_tty(&os_windows, "ttys999");
assert!(result.is_none());
}
#[test]
fn test_json_parsing_complex() {
let json = r#"[
{
"id": 1,
"tabs": [
{
"id": 10,
"windows": [
{
"id": 100,
"foreground_processes": [
{"pid": 1234, "cmdline": ["/bin/zsh"], "cwd": "/Users/test"}
]
},
{
"id": 101,
"foreground_processes": [
{"pid": 5678, "cmdline": ["/bin/bash"], "cwd": "/tmp"}
]
}
]
},
{
"id": 11,
"windows": [
{
"id": 110,
"foreground_processes": [
{"pid": 9999, "cmdline": ["vim"], "cwd": "/home"}
]
}
]
}
]
},
{
"id": 2,
"tabs": [
{
"id": 20,
"windows": [
{
"id": 200,
"foreground_processes": [
{"pid": 4321, "cmdline": ["htop"], "cwd": "/"}
]
}
]
}
]
}
]"#;
let os_windows: Vec<serde_json::Value> = serde_json::from_str(json).unwrap();
assert_eq!(os_windows.len(), 2);
let tabs = os_windows[0]["tabs"].as_array().unwrap();
assert_eq!(tabs.len(), 2);
let windows = tabs[0]["windows"].as_array().unwrap();
assert_eq!(windows.len(), 2);
}
}