Эх сурвалжийг харах

Merge pull request #547 from smatheusblu/fix/companion-geometry-position

fix: keep companion positioned on niri resize
Alvin 1 сар өмнө
parent
commit
b7337c8d57

+ 320 - 68
companion/src/app.rs

@@ -1,11 +1,16 @@
 use std::sync::mpsc::Receiver;
+use std::sync::{
+    atomic::{AtomicU64, Ordering},
+    Arc,
+};
 use std::time::Duration;
 
 use eframe::egui;
 
 use crate::gifs::Gifs;
+use crate::niri;
 use crate::screen::primary_size;
-use crate::state::{read_state, start_watcher, SessionInfo};
+use crate::state::{read_state, start_watcher, CompanionConfigState, SessionInfo};
 
 const DEFAULT_SIZE: f32 = 120.0;
 const GAP: f32 = 10.0;
@@ -21,6 +26,23 @@ const SIZE_KEY: &str = "companion_size";
 const MENU_OPEN_KEY: &str = "companion_menu_open";
 const MENU_POS_KEY: &str = "companion_menu_pos";
 
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct WindowGeometryKey {
+    session_id: String,
+    position: String,
+    size_px: u32,
+    cols: u32,
+    rows: u32,
+    screen_w: u32,
+    screen_h: u32,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+struct ConfigKey {
+    position: String,
+    size: String,
+}
+
 fn grid_cols(n: usize) -> usize {
     match n {
         0 | 1 => 1,
@@ -36,6 +58,51 @@ fn grid_dims(n: usize) -> (usize, usize) {
     (cols, rows)
 }
 
+fn size_from_config(size: &str) -> f32 {
+    match size {
+        "small" => 80.0,
+        "medium" => 120.0,
+        "large" => 160.0,
+        "xl" | "xlarge" => 200.0,
+        _ => DEFAULT_SIZE,
+    }
+}
+
+fn config_key(config: Option<&CompanionConfigState>) -> Option<ConfigKey> {
+    config.map(|cfg| ConfigKey {
+        position: cfg.position.clone(),
+        size: cfg.size.clone(),
+    })
+}
+
+fn apply_config(key: Option<&ConfigKey>, position: &mut String, size: &mut f32) {
+    if let Some(cfg) = key {
+        *position = cfg.position.clone();
+        *size = size_from_config(&cfg.size);
+    } else {
+        *position = "bottom-right".to_string();
+        *size = DEFAULT_SIZE;
+    }
+}
+
+fn window_size(cell: f32, cols: usize, rows: usize) -> [f32; 2] {
+    [cell * cols as f32, cell * rows as f32]
+}
+
+pub(crate) fn place_window(position: &str, screen: [f32; 2], win: [f32; 2]) -> [f32; 2] {
+    let (screen_w, screen_h) = (screen[0], screen[1]);
+    let (win_w, win_h) = (win[0], win[1]);
+    let (x, y) = match position {
+        "bottom-left" => (GAP, screen_h - win_h - GAP),
+        "top-right" => (screen_w - win_w - GAP, GAP),
+        "top-left" => (GAP, GAP),
+        _ => (screen_w - win_w - GAP, screen_h - win_h - GAP),
+    };
+    let x_max = (screen_w - win_w - GAP).max(GAP);
+    let y_max = (screen_h - win_h - GAP).max(GAP);
+    [x.clamp(GAP, x_max), y.clamp(GAP, y_max)]
+}
+
 fn cell_rects(agents: usize, cols: usize, rows: usize, cell: f32) -> Vec<egui::Rect> {
     let mut rects = Vec::with_capacity(agents);
     let full_rows = agents / cols;
@@ -87,12 +154,6 @@ fn choose_session(sessions: &[SessionInfo]) -> Option<usize> {
         .or_else(|| sessions.first().map(|_| 0))
 }
 
-fn clamp_viewport_pos(pos: egui::Pos2, win_w: f32, win_h: f32, screen: [f32; 2]) -> egui::Pos2 {
-    let x_max = (screen[0] - win_w - GAP).max(GAP);
-    let y_max = (screen[1] - win_h - GAP).max(GAP);
-    egui::pos2(pos.x.clamp(GAP, x_max), pos.y.clamp(GAP, y_max))
-}
-
 pub struct CompanionApp {
     state_path: std::path::PathBuf,
     sessions: Vec<SessionInfo>,
@@ -103,8 +164,9 @@ pub struct CompanionApp {
     screen: [f32; 2],
     position: String,
     has_modern_config: bool,
-    applied_size: Option<(String, f32, u32, u32)>,
-    applied_position: Option<(String, String)>,
+    applied_config: Option<ConfigKey>,
+    applied_geometry: Option<WindowGeometryKey>,
+    niri_generation: Arc<AtomicU64>,
 }
 
 impl CompanionApp {
@@ -116,15 +178,8 @@ impl CompanionApp {
         let mut initial_size = DEFAULT_SIZE;
         let mut position = "bottom-right".to_string();
         let has_modern_config = state.config.is_some();
-        if let Some(ref cfg) = state.config {
-            initial_size = match cfg.size.as_str() {
-                "small" => 80.0,
-                "medium" => 120.0,
-                "large" => 160.0,
-                _ => 120.0,
-            };
-            position = cfg.position.clone();
-        }
+        let applied_config = config_key(state.config.as_ref());
+        apply_config(applied_config.as_ref(), &mut position, &mut initial_size);
 
         let rx = start_watcher(state_path.clone());
 
@@ -138,27 +193,31 @@ impl CompanionApp {
             screen: primary_size(),
             position,
             has_modern_config,
-            applied_size: None,
-            applied_position: None,
+            applied_config,
+            applied_geometry: None,
+            niri_generation: Arc::new(AtomicU64::new(0)),
         }
     }
 
-    fn poll(&mut self) {
+    fn poll(&mut self) -> bool {
         if self.rx.try_recv().is_ok() {
             while self.rx.try_recv().is_ok() {}
             let state = read_state(&self.state_path);
             self.sessions = state.sessions;
             self.has_modern_config = state.config.is_some();
-            if let Some(ref cfg) = state.config {
-                self.position = cfg.position.clone();
-            } else {
-                self.position = "bottom-right".to_string();
+            let next_config = config_key(state.config.as_ref());
+            let config_changed = self.applied_config != next_config;
+            if config_changed {
+                apply_config(next_config.as_ref(), &mut self.position, &mut self.size);
+                self.applied_config = next_config;
             }
+            return config_changed;
         }
 
         let has_modern = self.has_modern_config;
         self.sessions
             .retain(|s| s.pid.map(is_pid_alive).unwrap_or(!has_modern));
+        false
     }
 
     fn update_screen_from_ctx(&mut self, ctx: &egui::Context) {
@@ -168,23 +227,11 @@ impl CompanionApp {
             }
         }
     }
-
-    fn initial_pos(&self, win_w: f32, win_h: f32) -> [f32; 2] {
-        let (x, y) = match self.position.as_str() {
-            "bottom-left" => (GAP, self.screen[1] - win_h - GAP),
-            "top-right" => (self.screen[0] - win_w - GAP, GAP),
-            "top-left" => (GAP, GAP),
-            _ => (self.screen[0] - win_w - GAP, self.screen[1] - win_h - GAP),
-        };
-        let x_max = (self.screen[0] - win_w - GAP).max(GAP);
-        let y_max = (self.screen[1] - win_h - GAP).max(GAP);
-        [x.clamp(GAP, x_max), y.clamp(GAP, y_max)]
-    }
 }
 
 impl eframe::App for CompanionApp {
     fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
-        self.poll();
+        let config_changed = self.poll();
         self.update_screen_from_ctx(ctx);
 
         let quit = ctx.data(|d| {
@@ -200,9 +247,13 @@ impl eframe::App for CompanionApp {
             self.gifs.register(ctx);
             ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
             self.registered = true;
+        } else if config_changed {
+            // Config/state changes are the source of truth. A right-click picker
+            // selection remains local until the config tuple changes.
+            ctx.data_mut(|d| d.insert_temp(egui::Id::new(SIZE_KEY), self.size));
         }
 
-        self.size = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
+        self.size = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(self.size));
 
         let Some(selected_idx) = choose_session(&self.sessions) else {
             egui::CentralPanel::default()
@@ -228,38 +279,25 @@ impl eframe::App for CompanionApp {
         };
         let n = agent_uris.len().max(1);
         let (cols, rows) = grid_dims(n);
-        let win_w = self.size * cols as f32;
-        let win_h = self.size * rows as f32;
-
-        let size_layout = (
-            session.session_id.clone(),
-            self.size,
-            cols as u32,
-            rows as u32,
-        );
-        if self.applied_size.as_ref() != Some(&size_layout) {
-            let old_outer_rect = ctx.input(|i| i.viewport().outer_rect);
-            let monitor_size = ctx.input(|i| i.viewport().monitor_size);
-            let screen = monitor_size
-                .filter(|size| 1.0 < size.x && 1.0 < size.y)
-                .map(|size| [size.x, size.y])
-                .unwrap_or(self.screen);
+        let [win_w, win_h] = window_size(self.size, cols, rows);
+
+        let geometry = WindowGeometryKey {
+            session_id: session.session_id.clone(),
+            position: self.position.clone(),
+            size_px: self.size.round() as u32,
+            cols: cols as u32,
+            rows: rows as u32,
+            screen_w: self.screen[0].round() as u32,
+            screen_h: self.screen[1].round() as u32,
+        };
+        if self.applied_geometry.as_ref() != Some(&geometry) {
             ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
-            if let Some(rect) = old_outer_rect {
-                ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(clamp_viewport_pos(
-                    rect.min, win_w, win_h, screen,
-                )));
-            }
-            self.applied_size = Some(size_layout);
-        }
-
-        let position_layout = (session.session_id.clone(), self.position.clone());
-        if self.applied_position.as_ref() != Some(&position_layout) {
-            let pos = self.initial_pos(win_w, win_h);
+            let pos = place_window(&self.position, self.screen, [win_w, win_h]);
             ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
                 pos[0], pos[1],
             )));
-            self.applied_position = Some(position_layout);
+            self.applied_geometry = Some(geometry);
+            self.spawn_niri_fallback([win_w, win_h]);
         }
 
         if ctx.input(|i| i.pointer.primary_down()) {
@@ -290,6 +328,34 @@ impl eframe::App for CompanionApp {
     }
 }
 
+impl CompanionApp {
+    fn spawn_niri_fallback(&self, win_size: [f32; 2]) {
+        let socket = match std::env::var("NIRI_SOCKET") {
+            Ok(socket) if !socket.is_empty() => socket,
+            _ => return,
+        };
+        let desired = place_window(&self.position, self.screen, win_size);
+        if !desired[0].is_finite() || !desired[1].is_finite() {
+            return;
+        }
+        let generation = self.niri_generation.fetch_add(1, Ordering::Relaxed) + 1;
+        let position = self.position.clone();
+        let screen = self.screen;
+        let niri_generation = Arc::clone(&self.niri_generation);
+        std::thread::spawn(move || {
+            niri::retry_move_current_window(
+                socket,
+                std::process::id(),
+                generation,
+                niri_generation,
+                position,
+                screen,
+                win_size,
+            );
+        });
+    }
+}
+
 fn render_session(
     ui: &mut egui::Ui,
     ctx: &egui::Context,
@@ -472,7 +538,11 @@ fn is_pid_alive(_pid: u32) -> bool {
 
 #[cfg(test)]
 mod tests {
-    use super::{choose_session, SessionInfo};
+    use super::{
+        apply_config, choose_session, config_key, grid_dims, place_window, size_from_config,
+        window_size, ConfigKey, SessionInfo, WindowGeometryKey, GAP,
+    };
+    use crate::state::CompanionConfigState;
 
     fn session(id: &str, status: &str, agents: &[&str]) -> SessionInfo {
         SessionInfo {
@@ -520,4 +590,186 @@ mod tests {
         ];
         assert_eq!(choose_session(&sessions), Some(0));
     }
+
+    #[test]
+    fn config_size_defaults_and_presets_work() {
+        assert_eq!(size_from_config("small"), 80.0);
+        assert_eq!(size_from_config("medium"), 120.0);
+        assert_eq!(size_from_config("large"), 160.0);
+        assert_eq!(size_from_config("xl"), 200.0);
+        assert_eq!(size_from_config("unknown"), 120.0);
+    }
+
+    #[test]
+    fn top_left_is_gap_gap() {
+        assert_eq!(
+            place_window("top-left", [1440.0, 900.0], [240.0, 240.0]),
+            [GAP, GAP]
+        );
+    }
+
+    #[test]
+    fn bottom_right_stays_anchored_when_height_grows() {
+        let small = place_window("bottom-right", [1440.0, 900.0], [240.0, 240.0]);
+        let tall = place_window("bottom-right", [1440.0, 900.0], [240.0, 480.0]);
+        assert!(tall[1] < small[1]);
+        assert!((tall[1] + 480.0 + GAP - 900.0).abs() < 0.01);
+    }
+
+    #[test]
+    fn top_right_moves_left_when_width_grows() {
+        let small = place_window("top-right", [1440.0, 900.0], [240.0, 240.0]);
+        let wide = place_window("top-right", [1440.0, 900.0], [480.0, 240.0]);
+        assert!(wide[0] < small[0]);
+    }
+
+    #[test]
+    fn bottom_right_stays_anchored_when_width_grows() {
+        let small = place_window("bottom-right", [1440.0, 900.0], [240.0, 240.0]);
+        let wide = place_window("bottom-right", [1440.0, 900.0], [480.0, 240.0]);
+        assert!(wide[0] < small[0]);
+        assert!((wide[0] + 480.0 + GAP - 1440.0).abs() < 0.01);
+    }
+
+    #[test]
+    fn bottom_left_stays_anchored_when_height_grows() {
+        let small = place_window("bottom-left", [1440.0, 900.0], [240.0, 240.0]);
+        let tall = place_window("bottom-left", [1440.0, 900.0], [240.0, 480.0]);
+        assert_eq!(tall[0], GAP);
+        assert!(tall[1] < small[1]);
+        assert!((tall[1] + 480.0 + GAP - 900.0).abs() < 0.01);
+    }
+
+    #[test]
+    fn oversized_window_uses_best_effort_gap_anchor() {
+        assert_eq!(
+            place_window("bottom-right", [300.0, 300.0], [500.0, 500.0]),
+            [GAP, GAP]
+        );
+    }
+
+    #[test]
+    fn geometry_key_changes_with_layout_inputs() {
+        let base = WindowGeometryKey {
+            session_id: "a".into(),
+            position: "bottom-right".into(),
+            size_px: 120,
+            cols: 1,
+            rows: 1,
+            screen_w: 1440,
+            screen_h: 900,
+        };
+        assert_ne!(
+            base,
+            WindowGeometryKey {
+                cols: 2,
+                ..base.clone()
+            }
+        );
+        assert_ne!(
+            base,
+            WindowGeometryKey {
+                rows: 2,
+                ..base.clone()
+            }
+        );
+        assert_ne!(
+            base,
+            WindowGeometryKey {
+                size_px: 160,
+                ..base.clone()
+            }
+        );
+        assert_ne!(
+            base,
+            WindowGeometryKey {
+                screen_w: 1600,
+                ..base.clone()
+            }
+        );
+        assert_ne!(
+            base,
+            WindowGeometryKey {
+                position: "top-left".into(),
+                ..base.clone()
+            }
+        );
+        assert_ne!(
+            base.clone(),
+            WindowGeometryKey {
+                session_id: "b".into(),
+                ..base
+            }
+        );
+    }
+
+    #[test]
+    fn grid_dims_remains_stable() {
+        assert_eq!(grid_dims(1), (1, 1));
+        assert_eq!(grid_dims(4), (2, 2));
+    }
+
+    #[test]
+    fn window_size_scales_with_grid() {
+        assert_eq!(window_size(120.0, 2, 3), [240.0, 360.0]);
+    }
+
+    #[test]
+    fn config_key_tracks_only_config_position_and_size() {
+        let cfg = CompanionConfigState {
+            enabled: true,
+            position: "top-left".into(),
+            size: "large".into(),
+        };
+        assert_eq!(
+            config_key(Some(&cfg)),
+            Some(ConfigKey {
+                position: "top-left".into(),
+                size: "large".into(),
+            })
+        );
+        assert_eq!(config_key(None), None);
+    }
+
+    #[test]
+    fn config_tuple_change_detection_preserves_local_picker_on_session_updates() {
+        let previous = Some(ConfigKey {
+            position: "bottom-right".into(),
+            size: "medium".into(),
+        });
+        let unchanged = Some(ConfigKey {
+            position: "bottom-right".into(),
+            size: "medium".into(),
+        });
+        let moved = Some(ConfigKey {
+            position: "top-left".into(),
+            size: "medium".into(),
+        });
+        let resized = Some(ConfigKey {
+            position: "bottom-right".into(),
+            size: "large".into(),
+        });
+
+        assert_eq!(previous, unchanged);
+        assert_ne!(previous, moved);
+        assert_ne!(previous, resized);
+    }
+
+    #[test]
+    fn apply_config_updates_size_only_for_config_changes() {
+        let mut position = "bottom-right".to_string();
+        let mut size = 200.0;
+        let cfg = ConfigKey {
+            position: "top-left".into(),
+            size: "small".into(),
+        };
+
+        apply_config(Some(&cfg), &mut position, &mut size);
+        assert_eq!(position, "top-left");
+        assert_eq!(size, 80.0);
+
+        apply_config(None, &mut position, &mut size);
+        assert_eq!(position, "bottom-right");
+        assert_eq!(size, 120.0);
+    }
 }

+ 1 - 0
companion/src/main.rs

@@ -2,6 +2,7 @@
 
 mod app;
 mod gifs;
+mod niri;
 mod screen;
 mod singleton;
 mod state;

+ 460 - 0
companion/src/niri.rs

@@ -0,0 +1,460 @@
+use std::process::Command;
+use std::sync::{
+    atomic::{AtomicU64, Ordering},
+    Arc,
+};
+use std::time::Duration;
+
+use serde::Deserialize;
+
+const APP_ID: &str = "oh-my-opencode-slim-companion";
+const TITLE: &str = "oh-my-opencode-slim-companion";
+const GAP: f64 = 10.0;
+
+#[derive(Debug, Deserialize)]
+struct NiriWindow {
+    id: u64,
+    pid: Option<u32>,
+    app_id: Option<String>,
+    title: Option<String>,
+    is_floating: Option<bool>,
+    layout: Option<NiriLayout>,
+}
+
+#[derive(Debug, Deserialize)]
+struct NiriOutput {
+    logical: Option<NiriOutputLogical>,
+}
+
+#[derive(Clone, Copy, Debug, Deserialize, PartialEq)]
+struct NiriOutputLogical {
+    x: f64,
+    y: f64,
+    width: f64,
+    height: f64,
+}
+
+#[derive(Debug, Deserialize)]
+struct NiriLayout {
+    tile_pos_in_workspace_view: Option<[f64; 2]>,
+}
+
+pub fn retry_move_current_window(
+    socket: String,
+    pid: u32,
+    generation: u64,
+    current_generation: Arc<AtomicU64>,
+    position: String,
+    screen: [f32; 2],
+    win_size: [f32; 2],
+) {
+    for _ in 0..6 {
+        std::thread::sleep(Duration::from_millis(150));
+        if current_generation.load(Ordering::Relaxed) != generation {
+            return;
+        }
+        let Some((id, delta)) = resolve_move(&socket, pid, &position, screen, win_size) else {
+            continue;
+        };
+        if move_window(&socket, &id, delta).is_ok() {
+            return;
+        }
+    }
+}
+
+fn resolve_move(
+    socket: &str,
+    pid: u32,
+    position: &str,
+    screen: [f32; 2],
+    win_size: [f32; 2],
+) -> Option<(String, [i32; 2])> {
+    let windows_output = Command::new("niri")
+        .arg("msg")
+        .arg("--json")
+        .arg("windows")
+        .env("NIRI_SOCKET", socket)
+        .output()
+        .ok()?;
+    if !windows_output.status.success() {
+        return None;
+    }
+    let outputs_output = Command::new("niri")
+        .arg("msg")
+        .arg("--json")
+        .arg("outputs")
+        .env("NIRI_SOCKET", socket)
+        .output()
+        .ok();
+    let outputs_json = outputs_output
+        .as_ref()
+        .filter(|output| output.status.success())
+        .map(|output| output.stdout.as_slice());
+
+    resolve_move_from_json(
+        &windows_output.stdout,
+        outputs_json,
+        pid,
+        position,
+        screen,
+        win_size,
+    )
+}
+
+fn resolve_move_from_json(
+    windows_json: &[u8],
+    outputs_json: Option<&[u8]>,
+    pid: u32,
+    position: &str,
+    screen: [f32; 2],
+    win_size: [f32; 2],
+) -> Option<(String, [i32; 2])> {
+    let windows: Vec<NiriWindow> = serde_json::from_slice(windows_json).ok()?;
+    let win = windows.into_iter().find(|w| matches_window(w, pid))?;
+    let current = win.layout?.tile_pos_in_workspace_view?;
+    let output = outputs_json
+        .and_then(parse_outputs)
+        .and_then(|outputs| output_for_position(&outputs, current))
+        .unwrap_or(NiriOutputLogical {
+            x: 0.0,
+            y: 0.0,
+            width: screen[0] as f64,
+            height: screen[1] as f64,
+        });
+    let desired =
+        place_window_on_output(position, output, [win_size[0] as f64, win_size[1] as f64]);
+    let dx = (desired[0] - current[0]).round() as i32;
+    let dy = (desired[1] - current[1]).round() as i32;
+    if dx.abs() <= 1 && dy.abs() <= 1 {
+        return None;
+    }
+    let max_delta = movement_delta_limit(output);
+    if dx.abs() > max_delta || dy.abs() > max_delta {
+        return None;
+    }
+    Some((win.id.to_string(), [dx, dy]))
+}
+
+fn movement_delta_limit(output: NiriOutputLogical) -> i32 {
+    (output.width.max(output.height) * 2.0).ceil().max(1.0) as i32
+}
+
+fn parse_outputs(json: &[u8]) -> Option<Vec<NiriOutputLogical>> {
+    let outputs: std::collections::HashMap<String, NiriOutput> =
+        serde_json::from_slice(json).ok()?;
+    let logicals = outputs
+        .into_values()
+        .filter_map(|output| output.logical)
+        .filter(|logical| {
+            logical.x.is_finite()
+                && logical.y.is_finite()
+                && logical.width.is_finite()
+                && logical.height.is_finite()
+                && logical.width > 1.0
+                && logical.height > 1.0
+        })
+        .collect::<Vec<_>>();
+    (!logicals.is_empty()).then_some(logicals)
+}
+
+fn output_for_position(outputs: &[NiriOutputLogical], pos: [f64; 2]) -> Option<NiriOutputLogical> {
+    outputs
+        .iter()
+        .copied()
+        .find(|output| {
+            output.x <= pos[0]
+                && pos[0] < output.x + output.width
+                && output.y <= pos[1]
+                && pos[1] < output.y + output.height
+        })
+        .or_else(|| outputs.first().copied())
+}
+
+fn place_window_on_output(
+    position: &str,
+    output: NiriOutputLogical,
+    win_size: [f64; 2],
+) -> [f64; 2] {
+    let (win_w, win_h) = (win_size[0], win_size[1]);
+    let (x, y) = match position {
+        "bottom-left" => (output.x + GAP, output.y + output.height - win_h - GAP),
+        "top-right" => (output.x + output.width - win_w - GAP, output.y + GAP),
+        "top-left" => (output.x + GAP, output.y + GAP),
+        _ => (
+            output.x + output.width - win_w - GAP,
+            output.y + output.height - win_h - GAP,
+        ),
+    };
+    let x_min = output.x + GAP;
+    let y_min = output.y + GAP;
+    let x_max = (output.x + output.width - win_w - GAP).max(x_min);
+    let y_max = (output.y + output.height - win_h - GAP).max(y_min);
+    [x.clamp(x_min, x_max), y.clamp(y_min, y_max)]
+}
+
+fn matches_window(win: &NiriWindow, pid: u32) -> bool {
+    win.pid == Some(pid)
+        && win.is_floating == Some(true)
+        && (win.app_id.as_deref() == Some(APP_ID) || win.title.as_deref() == Some(TITLE))
+}
+
+fn move_window(socket: &str, id: &str, delta: [i32; 2]) -> std::io::Result<()> {
+    let args = build_move_args(id, delta);
+    Command::new("niri")
+        .args(args)
+        .env("NIRI_SOCKET", socket)
+        .output()
+        .map(|_| ())
+}
+
+pub(crate) fn build_move_args(id: &str, delta: [i32; 2]) -> Vec<String> {
+    vec![
+        "msg".into(),
+        "action".into(),
+        "move-floating-window".into(),
+        "--id".into(),
+        id.into(),
+        "-x".into(),
+        format_delta(delta[0]),
+        "-y".into(),
+        format_delta(delta[1]),
+    ]
+}
+
+fn format_delta(delta: i32) -> String {
+    if delta > 0 {
+        format!("+{delta}")
+    } else {
+        delta.to_string()
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::{
+        build_move_args, matches_window, output_for_position, parse_outputs,
+        place_window_on_output, resolve_move_from_json, NiriOutputLogical, NiriWindow,
+    };
+
+    const FIXTURE: &str = r#"[
+      {"id":1,"pid":11,"app_id":"other","title":"x","is_floating":true,"layout":{"tile_pos_in_workspace_view":[1,2]}},
+      {"id":2,"pid":1234,"app_id":"oh-my-opencode-slim-companion","title":"oh-my-opencode-slim-companion","is_floating":true,"layout":{"tile_pos_in_workspace_view":[2424,944]}},
+      {"id":3,"pid":1234,"app_id":"oh-my-opencode-slim-companion","title":"oh-my-opencode-slim-companion","is_floating":false,"layout":{"tile_pos_in_workspace_view":[0,0]}},
+      {"id":4,"pid":1234,"app_id":"wrong","title":"wrong","is_floating":true,"layout":{"tile_pos_in_workspace_view":[0,0]}},
+      {"id":5,"pid":9999,"app_id":"oh-my-opencode-slim-companion","title":"oh-my-opencode-slim-companion","is_floating":true,"layout":{"tile_pos_in_workspace_view":[0,0]}},
+      {"id":6,"pid":1234,"app_id":"oh-my-opencode-slim-companion","title":"oh-my-opencode-slim-companion","is_floating":true,"layout":null},
+      {"id":7,"pid":1234,"app_id":"oh-my-opencode-slim-companion","title":"oh-my-opencode-slim-companion","is_floating":true,"layout":{"tile_pos_in_workspace_view":null}}
+    ]"#;
+
+    const OUTPUTS: &str = r#"{
+      "HDMI-A-1": {"logical":{"x":0,"y":0,"width":2560,"height":1080,"scale":1,"transform":"Normal"}}
+    }"#;
+
+    #[test]
+    fn parse_fixture_json() {
+        let windows: Vec<NiriWindow> = serde_json::from_str(FIXTURE).unwrap();
+        assert_eq!(windows.len(), 7);
+    }
+
+    #[test]
+    fn command_args_builder_exact_args() {
+        assert_eq!(
+            build_move_args("2", [-234, -114]),
+            vec![
+                "msg",
+                "action",
+                "move-floating-window",
+                "--id",
+                "2",
+                "-x",
+                "-234",
+                "-y",
+                "-114"
+            ]
+            .into_iter()
+            .map(String::from)
+            .collect::<Vec<_>>()
+        );
+    }
+
+    #[test]
+    fn command_args_prefix_positive_deltas() {
+        assert_eq!(
+            build_move_args("2", [2180, 820]),
+            vec![
+                "msg",
+                "action",
+                "move-floating-window",
+                "--id",
+                "2",
+                "-x",
+                "+2180",
+                "-y",
+                "+820"
+            ]
+            .into_iter()
+            .map(String::from)
+            .collect::<Vec<_>>()
+        );
+    }
+
+    #[test]
+    fn select_matching_window_by_pid_and_identity() {
+        let windows: Vec<NiriWindow> = serde_json::from_str(FIXTURE).unwrap();
+        let ok = windows.into_iter().find(|w| matches_window(w, 1234));
+        assert_eq!(ok.unwrap().id, 2);
+    }
+
+    #[test]
+    fn reject_wrong_pid_app_id_and_non_floating() {
+        let windows: Vec<NiriWindow> = serde_json::from_str(FIXTURE).unwrap();
+        assert!(!matches_window(&windows[1], 9999));
+        assert!(!matches_window(&windows[2], 1234));
+        assert!(!matches_window(&windows[3], 1234));
+        assert!(!matches_window(&windows[0], 1234));
+    }
+
+    #[test]
+    fn compute_delta_for_observed_evidence() {
+        let desired = place_window_on_output(
+            "bottom-right",
+            NiriOutputLogical {
+                x: 0.0,
+                y: 0.0,
+                width: 2560.0,
+                height: 1080.0,
+            },
+            [360.0, 240.0],
+        );
+        assert_eq!(desired, [2190.0, 830.0]);
+        let dx = (desired[0] - 2424.0).round() as i32;
+        let dy = (desired[1] - 944.0).round() as i32;
+        assert_eq!([dx, dy], [-234, -114]);
+    }
+
+    #[test]
+    fn top_left_desired_is_gap_gap() {
+        assert_eq!(
+            place_window_on_output(
+                "top-left",
+                NiriOutputLogical {
+                    x: 0.0,
+                    y: 0.0,
+                    width: 2560.0,
+                    height: 1080.0,
+                },
+                [360.0, 240.0],
+            ),
+            [10.0, 10.0]
+        );
+    }
+
+    #[test]
+    fn no_op_when_already_positioned() {
+        let desired = place_window_on_output(
+            "top-left",
+            NiriOutputLogical {
+                x: 0.0,
+                y: 0.0,
+                width: 2560.0,
+                height: 1080.0,
+            },
+            [360.0, 240.0],
+        );
+        let dx = (desired[0] - 10.0).round() as i32;
+        let dy = (desired[1] - 10.0).round() as i32;
+        assert!(dx.abs() <= 1 && dy.abs() <= 1);
+    }
+
+    #[test]
+    fn resolve_move_uses_niri_output_bounds() {
+        assert_eq!(
+            resolve_move_from_json(
+                FIXTURE.as_bytes(),
+                Some(OUTPUTS.as_bytes()),
+                1234,
+                "bottom-right",
+                [2550.0, 1100.0],
+                [360.0, 240.0],
+            ),
+            Some(("2".into(), [-234, -114]))
+        );
+    }
+
+    #[test]
+    fn large_output_deltas_are_allowed_with_derived_limit() {
+        let windows = r#"[
+          {"id":2,"pid":1234,"app_id":"oh-my-opencode-slim-companion","title":"oh-my-opencode-slim-companion","is_floating":true,"layout":{"tile_pos_in_workspace_view":[8000,4000]}}
+        ]"#;
+        let outputs = r#"{
+          "big": {"logical":{"x":0,"y":0,"width":10000,"height":5000,"scale":1,"transform":"Normal"}}
+        }"#;
+        assert_eq!(
+            resolve_move_from_json(
+                windows.as_bytes(),
+                Some(outputs.as_bytes()),
+                1234,
+                "top-left",
+                [10000.0, 5000.0],
+                [120.0, 120.0],
+            ),
+            Some(("2".into(), [-7990, -3990]))
+        );
+    }
+
+    #[test]
+    fn non_zero_origin_output_places_relative_to_that_output() {
+        let output = NiriOutputLogical {
+            x: 1920.0,
+            y: 100.0,
+            width: 1280.0,
+            height: 720.0,
+        };
+        assert_eq!(
+            place_window_on_output("bottom-right", output, [120.0, 120.0]),
+            [3070.0, 690.0]
+        );
+    }
+
+    #[test]
+    fn output_selection_prefers_current_window_output() {
+        let outputs = vec![
+            NiriOutputLogical {
+                x: 0.0,
+                y: 0.0,
+                width: 1920.0,
+                height: 1080.0,
+            },
+            NiriOutputLogical {
+                x: 1920.0,
+                y: 0.0,
+                width: 1280.0,
+                height: 720.0,
+            },
+        ];
+        assert_eq!(
+            output_for_position(&outputs, [2000.0, 20.0]).unwrap(),
+            outputs[1]
+        );
+    }
+
+    #[test]
+    fn parse_outputs_ignores_invalid_outputs() {
+        assert_eq!(parse_outputs(OUTPUTS.as_bytes()).unwrap().len(), 1);
+        assert!(
+            parse_outputs(br#"{"bad":{"logical":{"x":0,"y":0,"width":0,"height":0}}}"#).is_none()
+        );
+    }
+
+    #[test]
+    fn no_op_on_missing_null_layout_fields() {
+        let windows: Vec<NiriWindow> = serde_json::from_str(FIXTURE).unwrap();
+        assert!(windows.iter().any(|w| w.layout.is_none()));
+        assert!(windows.iter().any(|w| {
+            w.layout
+                .as_ref()
+                .and_then(|l| l.tile_pos_in_workspace_view)
+                .is_none()
+        }));
+    }
+}

+ 6 - 1
src/utils/background-job-board.ts

@@ -328,7 +328,12 @@ export class BackgroundJobBoard {
     }
     const contextFiles = [...existing.values()]
       .filter((file) => file.lineCount >= this.readContextMinLines)
-      .sort((a, b) => b.lastReadAt - a.lastReadAt)
+      .sort(
+        (a, b) =>
+          b.lineCount - a.lineCount ||
+          b.lastReadAt - a.lastReadAt ||
+          a.path.localeCompare(b.path),
+      )
       .slice(0, this.readContextMaxFiles + 1);
     this.jobs.set(taskID, { ...job, contextFiles });
   }