focal 0.2.8

Terminal focus library - focus terminal windows and multiplexer panes
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
//! GNU screen-specific focus handler.
//!
//! Provides functionality to find and switch to a GNU screen window containing
//! a specific process.
//!
//! Unlike tmux which provides `list-panes -a` with `#{pane_pid}`, GNU screen
//! doesn't expose a direct window-to-PID mapping via CLI. However, screen sets
//! the `$WINDOW` environment variable in each window, which is inherited by
//! child processes. We use this to:
//!
//! 1. List all sessions with `screen -ls`
//! 2. Find screen server processes and check if target PID is a descendant
//! 3. Read target PID's `$WINDOW` env var to determine the window number
//! 4. Switch to that window using `screen -X select <window>`
//! 5. Focus the terminal running the screen client

use super::{Focuser, MuxFocusResult};
use crate::util::{DEFAULT_TIMEOUT, run_command_with_timeout};
use std::collections::HashMap;

/// Maximum depth to traverse when walking up the parent process chain.
/// This prevents infinite loops in case of unusual process tree structures.
/// 50 is generous - typical process depth from shell to target is 5-10 levels.
const MAX_PARENT_CHAIN_DEPTH: usize = 50;

/// Run a screen command with timeout protection.
fn run_screen_with_timeout(args: &[&str]) -> Option<std::process::Output> {
    run_command_with_timeout("screen", args, DEFAULT_TIMEOUT)
}

/// Find the screen window number for a process by reading its $WINDOW env var.
///
/// Screen sets `$WINDOW` in each window, and it's inherited by child processes.
/// We try the target PID first, then walk up its parent chain. If that fails
/// (e.g., for login shells where KERN_PROCARGS2 doesn't contain environment),
/// we look for sibling processes on the same TTY that DO have WINDOW.
fn find_window_number<S>(pid: i32, parent_map: &HashMap<i32, i32, S>) -> Option<String>
where
    S: std::hash::BuildHasher,
{
    // Strategy 1: Try to read $WINDOW from the target process or its ancestors
    let mut current_pid = pid;
    for _ in 0..MAX_PARENT_CHAIN_DEPTH {
        if let Some(environ) = prock::get_process_environ(current_pid)
            && let Some(window) = environ.get("WINDOW")
        {
            return Some(window.clone());
        }

        // Walk up to parent
        if let Some(&ppid) = parent_map.get(&current_pid) {
            if ppid <= 1 {
                break;
            }
            current_pid = ppid;
        } else {
            break;
        }
    }

    // Strategy 2: Find sibling processes on the same TTY
    // This handles cases where bash/login shells don't have environment
    // in KERN_PROCARGS2, but their child processes (node, python, etc.) do.
    let target_tty = prock::get_tty(pid)?;
    if target_tty == "?" {
        return None;
    }

    // Scan all processes on the same TTY looking for one with WINDOW
    for other_pid in prock::list_all_pids() {
        if other_pid == pid {
            continue;
        }
        if let Some(tty) = prock::get_tty(other_pid)
            && tty == target_tty
            && let Some(environ) = prock::get_process_environ(other_pid)
            && let Some(window) = environ.get("WINDOW")
        {
            return Some(window.clone());
        }
    }

    None
}

/// Switch to a specific window in a screen session.
///
/// Tries multiple approaches for compatibility with different screen versions:
/// 1. First tries `screen -p <window> -X select <window>`
/// 2. Falls back to sending keystrokes via `stuff` command
fn switch_to_window(session: &str, window: &str) -> bool {
    // Approach 1: Use -p to preselect the window, then select it
    let result = run_screen_with_timeout(&["-S", session, "-p", window, "-X", "select", window]);
    if result.as_ref().is_some_and(|o| o.status.success()) {
        return true;
    }

    // Approach 2: Send Ctrl-A + window number as keystrokes
    // This simulates what a user would type to switch windows
    // Ctrl-A is \x01, followed by the window number
    let keystroke = format!("\x01{}", window);
    run_screen_with_timeout(&["-S", session, "-X", "stuff", &keystroke])
        .is_some_and(|o| o.status.success())
}

/// Find and switch to the GNU screen window containing a process.
///
/// # Arguments
/// * `pid` - Process ID to find
/// * `parent_map` - Map of pid -> parent pid for ancestry checking
/// * `focuser` - Implementation of [`Focuser`] to focus the terminal window
///   after switching.
///
/// # Returns
/// The result of the focus operation.
pub fn focus_pane<S>(
    pid: i32,
    parent_map: &HashMap<i32, i32, S>,
    focuser: &dyn Focuser,
) -> MuxFocusResult
where
    S: std::hash::BuildHasher,
{
    // Step 1: List all active screen sessions
    let sessions = match list_sessions() {
        Some(s) if !s.is_empty() => s,
        _ => return MuxFocusResult::NotFound,
    };

    // Step 2: Find which session contains our target PID
    // We do this by finding screen server processes and checking descendants
    let server_pids = find_screen_servers();

    // Find which server contains our target PID
    let mut target_server: Option<i32> = None;
    for &server_pid in &server_pids {
        if prock::is_descendant_of(pid, server_pid, parent_map) {
            target_server = Some(server_pid);
            break;
        }
    }

    // Try to find the window number from $WINDOW env var
    let window_number = find_window_number(pid, parent_map);

    // If we found a server, try to identify which session it belongs to
    if let Some(server_pid) = target_server {
        // Try to match server to session and focus it
        if let Some(session_name) = find_session_for_server(server_pid, &sessions) {
            return focus_session_window(&session_name, window_number.as_deref(), focuser);
        }

        // If we couldn't match to a specific session but only have one, use it
        if sessions.len() == 1 {
            return focus_session_window(&sessions[0].name, window_number.as_deref(), focuser);
        }
    }

    // Fallback: If we only have one session, try it
    if sessions.len() == 1 {
        return focus_session_window(&sessions[0].name, window_number.as_deref(), focuser);
    }

    // Try each session that's attached and see which one we can focus
    for session in &sessions {
        if session.attached
            && let Some((client_tty, client_pid)) = get_session_client(&session.name)
        {
            // Try to switch to the specific window if we know it
            if let Some(ref window) = window_number {
                let _ = switch_to_window(&session.name, window);
            }

            let tty_device = client_tty.trim_start_matches("/dev/");
            let _ = focuser.focus(tty_device, client_pid);
            return MuxFocusResult::Switched {
                session: session.name.clone(),
                window: window_number.unwrap_or_else(|| "0".to_string()),
                pane: "0".to_string(),
            };
        }
    }

    MuxFocusResult::NotFound
}

/// Parsed screen session info.
#[derive(Debug, Clone)]
struct ScreenSession {
    /// Session name (the full pid.name format from screen -ls)
    name: String,
    /// The PID portion of the session name
    pid: i32,
    /// Whether the session has a client attached
    attached: bool,
}

/// List all active GNU screen sessions.
///
/// Parses output from `screen -ls` which looks like:
/// ```text
/// There is a screen on:
///     12345.session_name   (Attached)
/// 1 Socket in /var/folders/...
/// ```
fn list_sessions() -> Option<Vec<ScreenSession>> {
    let output = run_screen_with_timeout(&["-ls"])?;

    // screen -ls returns non-zero when sessions exist but are detached,
    // so we check stdout regardless of exit status
    let stdout = String::from_utf8_lossy(&output.stdout);

    let mut sessions = Vec::new();

    for line in stdout.lines() {
        let trimmed = line.trim();

        // Session lines start with a digit (the PID) and contain a dot
        // Format: "12345.session_name\t(Attached)" or "(Detached)"
        if trimmed.is_empty() {
            continue;
        }

        // Check if line starts with a digit (PID)
        let Some(first_char) = trimmed.chars().next() else {
            continue;
        };
        if !first_char.is_ascii_digit() {
            continue;
        }

        // Parse the session line
        // Split on whitespace to separate name from status
        let parts: Vec<&str> = trimmed.split_whitespace().collect();
        if parts.is_empty() {
            continue;
        }

        let name = parts[0];

        // Extract PID from name (format: pid.name or pid.tty.host)
        // Use continue to skip malformed lines rather than returning None
        let Some(pid_str) = name.split('.').next() else {
            continue;
        };
        let Ok(pid) = pid_str.parse::<i32>() else {
            continue;
        };

        // Check attachment status - look for "(Attached)" or "(Detached)"
        let attached = parts.iter().any(|p| p.contains("Attached"));

        sessions.push(ScreenSession {
            name: name.to_string(),
            pid,
            attached,
        });
    }

    Some(sessions)
}

/// Find all GNU screen server process IDs.
///
/// Screen server processes have the command name "screen" or "SCREEN".
fn find_screen_servers() -> Vec<i32> {
    let all_pids = prock::list_all_pids();
    let mut servers = Vec::new();

    for pid in all_pids {
        if let Some(comm) = prock::get_process_comm(pid) {
            // Screen server process is named "screen" or "SCREEN"
            if comm == "screen" || comm == "SCREEN" {
                servers.push(pid);
            }
        }
    }

    servers
}

/// Try to match a server PID to a session name.
///
/// Screen session names contain the server PID as the first component
/// (format: pid.name or pid.tty.host), so we can match directly.
fn find_session_for_server(server_pid: i32, sessions: &[ScreenSession]) -> Option<String> {
    // Direct match: session name starts with the server PID
    for session in sessions {
        if session.pid == server_pid {
            return Some(session.name.clone());
        }
    }

    // If only one session exists, it must be the one
    if sessions.len() == 1 {
        return Some(sessions[0].name.clone());
    }

    None
}

/// Get client info for a session (TTY and PID).
///
/// For screen, we find the client by looking for screen processes
/// that are attached to the session's socket.
fn get_session_client(session: &str) -> Option<(String, Option<i32>)> {
    // Extract the PID from the session name
    let session_pid: i32 = session.split('.').next()?.parse().ok()?;

    // Extract the TTY from the session name
    // Session format: "PID.TTY.HOSTNAME" e.g., "72743.ttys030.LX7GLVVW6C"
    // The TTY portion tells us which terminal the client is attached to
    let session_parts: Vec<&str> = session.split('.').collect();
    let session_tty = session_parts.get(1).copied();

    // Build parent map once for all ancestry checks
    let parent_map = prock::build_parent_map();

    // Find screen client processes attached to this session
    let all_pids = prock::list_all_pids();

    // Track the best fallback candidate (any screen client with TTY)
    let mut fallback_candidate: Option<(String, i32)> = None;

    for pid in all_pids {
        if let Some(comm) = prock::get_process_comm(pid)
            && (comm == "screen" || comm == "SCREEN")
            && pid != session_pid
            && let Some(tty) = prock::get_tty(pid)
            && !tty.is_empty()
            && tty != "?"
        {
            let tty_path = if tty.starts_with("/dev/") {
                tty.clone()
            } else {
                format!("/dev/{}", tty)
            };

            // Primary check: Match client TTY to session name
            // Session name contains the client's TTY (e.g., "72743.ttys030.hostname")
            if let Some(s_tty) = session_tty
                && (tty == s_tty || tty.ends_with(s_tty))
            {
                return Some((tty_path, Some(pid)));
            }

            // Secondary checks: descendant/parent relationships
            if prock::is_descendant_of(pid, session_pid, &parent_map)
                || parent_map.get(&pid) == Some(&session_pid)
            {
                return Some((tty_path, Some(pid)));
            }

            // Track as fallback candidate if we haven't found one yet
            if fallback_candidate.is_none() {
                fallback_candidate = Some((tty_path, pid));
            }
        }
    }

    // Return fallback if no session-specific client was found
    fallback_candidate.map(|(tty, pid)| (tty, Some(pid)))
}

/// Focus a session and its terminal window, optionally switching to a specific window.
fn focus_session_window(
    session: &str,
    window: Option<&str>,
    focuser: &dyn Focuser,
) -> MuxFocusResult {
    // Get client TTY for terminal focus
    if let Some((client_tty, client_pid)) = get_session_client(session) {
        let tty_device = client_tty.trim_start_matches("/dev/");

        // Detect which terminal owns the client TTY BEFORE focusing
        let terminal = crate::detect::terminal(tty_device);

        // Focus the terminal window
        let _ = focuser.focus(tty_device, client_pid);

        // Switch to the specific screen window
        if let Some(window_num) = window {
            // Try screen -X command first (works on newer versions)
            let _ = switch_to_window(session, window_num);

            // Send keystrokes to the SPECIFIC terminal session
            // Uses terminal-native API (e.g., iTerm2's write text) for reliability
            if let Some(ref term) = terminal {
                send_screen_keystroke_to_terminal(window_num, tty_device, term);
            } else {
                // Fallback to frontmost app if terminal unknown
                send_screen_keystroke(window_num);
            }
        }

        MuxFocusResult::Switched {
            session: session.to_string(),
            window: window.unwrap_or("0").to_string(),
            pane: "0".to_string(),
        }
    } else {
        MuxFocusResult::NoClient {
            session: session.to_string(),
        }
    }
}

/// Send screen window-switch command to a specific terminal.
/// Uses terminal-native APIs when available for reliability.
#[cfg(target_os = "macos")]
fn send_screen_keystroke_to_terminal(window: &str, tty: &str, terminal: &crate::detect::Terminal) {
    use crate::detect::TerminalKind;

    // Only handle single-digit window numbers (0-9)
    if window.len() != 1 || !window.chars().next().is_some_and(|c| c.is_ascii_digit()) {
        return;
    }

    match &terminal.kind {
        TerminalKind::ITerm2 => {
            // Use iTerm2's native API to write directly to the session with matching TTY
            // This is more reliable than keystrokes which go to the frontmost tab
            let script = format!(
                r#"
                tell application "iTerm2"
                    repeat with w in windows
                        repeat with t in tabs of w
                            repeat with s in sessions of t
                                if tty of s contains "{tty}" then
                                    -- Send Ctrl-A (ASCII 1) followed by window number
                                    tell s to write text (ASCII character 1) & "{window}" without newline
                                    return true
                                end if
                            end repeat
                        end repeat
                    end repeat
                    return false
                end tell
                "#,
                tty = tty,
                window = window
            );
            let _ = std::process::Command::new("osascript")
                .args(["-e", &script])
                .output();
        }
        TerminalKind::TerminalApp => {
            // Terminal.app: use System Events keystrokes as fallback
            send_screen_keystroke(window);
        }
        _ => {
            // Other terminals: use generic keystroke approach
            send_screen_keystroke(window);
        }
    }
}

#[cfg(not(target_os = "macos"))]
fn send_screen_keystroke_to_terminal(
    _window: &str,
    _tty: &str,
    _terminal: &crate::detect::Terminal,
) {
    // No-op on non-macOS platforms
}

/// Send screen window-switch keystroke via AppleScript (fallback).
/// Sends Ctrl-A followed by the window number to the frontmost application.
#[cfg(target_os = "macos")]
fn send_screen_keystroke(window: &str) {
    // Only handle single-digit window numbers (0-9)
    if window.len() != 1 || !window.chars().next().is_some_and(|c| c.is_ascii_digit()) {
        return;
    }

    // AppleScript to send Ctrl-A followed by the window number
    // key code 0 is 'a', with control down sends Ctrl-A
    let script = format!(
        r#"
        delay 0.1
        tell application "System Events"
            key code 0 using control down
            delay 0.05
            keystroke "{}"
        end tell
        "#,
        window
    );

    let _ = std::process::Command::new("osascript")
        .args(["-e", &script])
        .output();
}

#[cfg(not(target_os = "macos"))]
fn send_screen_keystroke(_window: &str) {
    // No-op on non-macOS platforms
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Mock focuser for testing.
    struct MockFocuser;

    impl Focuser for MockFocuser {
        fn focus(&self, _tty: &str, _client_pid: Option<i32>) -> bool {
            false
        }
    }

    #[test]
    fn test_focus_pane_nonexistent_pid() {
        let parent_map = prock::build_parent_map();
        let focuser = MockFocuser;
        // Test with a PID that won't be in any screen session
        let result = focus_pane(999_999_999, &parent_map, &focuser);
        // If screen isn't running, we get NotFound
        // If screen IS running but pid isn't found, we might still get Switched
        // (best-effort fallback to first session) or NotFound
        match result {
            MuxFocusResult::NotFound => {}
            MuxFocusResult::Switched { .. } => {
                // If screen is running, we might focus a session even if PID not found
            }
            MuxFocusResult::NoClient { .. } => {
                // Session found but no client attached
            }
            MuxFocusResult::Error(_) => {}
        }
    }

    #[test]
    fn test_list_sessions() {
        // Should return None when screen isn't running, or a list when it is
        let sessions = list_sessions();
        // Either None or Some with valid list
        if let Some(s) = sessions {
            assert!(s.len() < 1000); // Sanity check
        }
    }

    #[test]
    fn test_find_screen_servers() {
        // Should return empty when screen isn't running, or server PIDs when it is
        let servers = find_screen_servers();
        assert!(servers.len() < 1000); // Sanity check
    }

    #[test]
    fn test_find_session_for_server_single_session() {
        // When there's only one session, it should always return that session
        let sessions = vec![ScreenSession {
            name: "12345.my-session".to_string(),
            pid: 12345,
            attached: true,
        }];
        let result = find_session_for_server(12345, &sessions);
        assert_eq!(result, Some("12345.my-session".to_string()));
    }

    #[test]
    fn test_find_session_for_server_by_pid() {
        // Should match by PID in session name
        let sessions = vec![
            ScreenSession {
                name: "12345.session1".to_string(),
                pid: 12345,
                attached: true,
            },
            ScreenSession {
                name: "67890.session2".to_string(),
                pid: 67890,
                attached: false,
            },
        ];
        let result = find_session_for_server(67890, &sessions);
        assert_eq!(result, Some("67890.session2".to_string()));
    }

    #[test]
    fn test_find_session_for_server_not_found() {
        let sessions = vec![
            ScreenSession {
                name: "12345.session1".to_string(),
                pid: 12345,
                attached: true,
            },
            ScreenSession {
                name: "67890.session2".to_string(),
                pid: 67890,
                attached: false,
            },
        ];
        let result = find_session_for_server(99999, &sessions);
        assert_eq!(result, None);
    }

    #[test]
    fn test_parse_session_line() {
        // Test parsing logic from list_sessions
        let line = "12345.pts-0.hostname\t(Attached)";
        let parts: Vec<&str> = line.split_whitespace().collect();
        assert!(!parts.is_empty());

        let name = parts[0];
        assert_eq!(name, "12345.pts-0.hostname");

        let pid_str = name.split('.').next().unwrap();
        let pid: i32 = pid_str.parse().unwrap();
        assert_eq!(pid, 12345);

        let attached = parts.iter().any(|p| p.contains("Attached"));
        assert!(attached);
    }

    #[test]
    fn test_parse_detached_session() {
        let line = "54321.my-session\t(Detached)";
        let parts: Vec<&str> = line.split_whitespace().collect();

        let name = parts[0];
        assert_eq!(name, "54321.my-session");

        let attached = parts.iter().any(|p| p.contains("Attached"));
        assert!(!attached);

        let detached = parts.iter().any(|p| p.contains("Detached"));
        assert!(detached);
    }

    #[test]
    fn test_find_window_number_not_in_screen() {
        // When not running in a screen session, $WINDOW should not be set
        let parent_map = prock::build_parent_map();
        let our_pid = std::process::id() as i32;

        // This test may pass or fail depending on whether we're in a screen session
        // Just verify it doesn't crash and returns a reasonable value
        let result = find_window_number(our_pid, &parent_map);

        // If we're not in screen, result should be None
        // If we ARE in screen, result should be a valid number string
        if let Some(window) = result {
            // Should be a valid number
            assert!(
                window.parse::<i32>().is_ok(),
                "WINDOW should be a number, got: {}",
                window
            );
        }
    }

    #[test]
    fn test_find_window_number_invalid_pid() {
        let parent_map = prock::build_parent_map();
        // Non-existent PID should return None
        let result = find_window_number(999_999_999, &parent_map);
        assert!(result.is_none());
    }
}