Przeglądaj źródła

Merge branch 'pr-542'

Alvin Unreal 1 miesiąc temu
rodzic
commit
cdeff9c972

+ 1 - 1
companion/Cargo.lock

@@ -2319,7 +2319,7 @@ dependencies = [
 
 [[package]]
 name = "oh-my-opencode-slim-companion"
-version = "0.1.0"
+version = "0.1.1"
 dependencies = [
  "dirs",
  "eframe",

+ 1 - 1
companion/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 name = "oh-my-opencode-slim-companion"
-version = "0.1.0"
+version = "0.1.1"
 edition = "2021"
 description = "Desktop companion for oh-my-opencode-slim — shows active agent GIFs per session"
 

+ 264 - 265
companion/src/app.rs

@@ -1,4 +1,3 @@
-use std::collections::HashSet;
 use std::sync::mpsc::Receiver;
 use std::time::Duration;
 
@@ -18,15 +17,10 @@ const SIZE_PRESETS: &[(&str, f32)] = &[
     ("XL  ·  200px", 200.0),
 ];
 
-const SESSIONS_KEY: &str = "companion_sessions";
 const SIZE_KEY: &str = "companion_size";
 const MENU_OPEN_KEY: &str = "companion_menu_open";
 const MENU_POS_KEY: &str = "companion_menu_pos";
 
-// Per session: (id, cwd, agents, status)
-type SessionSnapshot = Vec<(String, String, Vec<String>, String)>;
-
-/// Grid columns for N agents.
 fn grid_cols(n: usize) -> usize {
     match n {
         0 | 1 => 1,
@@ -35,7 +29,6 @@ fn grid_cols(n: usize) -> usize {
     }
 }
 
-/// Returns (cols, rows) for N agents.
 fn grid_dims(n: usize) -> (usize, usize) {
     let n = n.max(1);
     let cols = grid_cols(n);
@@ -43,7 +36,6 @@ fn grid_dims(n: usize) -> (usize, usize) {
     (cols, rows)
 }
 
-/// Cell rects for each agent, with orphan cells centered in the last row.
 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;
@@ -68,21 +60,45 @@ fn cell_rects(agents: usize, cols: usize, rows: usize, cell: f32) -> Vec<egui::R
         }
     }
 
-    let _ = rows; // used by caller for window sizing
+    let _ = rows;
     rects
 }
 
+fn choose_session(sessions: &[SessionInfo]) -> Option<usize> {
+    sessions
+        .iter()
+        .enumerate()
+        .find(|(_, s)| s.status == "waiting-input")
+        .map(|(i, _)| i)
+        .or_else(|| {
+            sessions
+                .iter()
+                .enumerate()
+                .find(|(_, s)| s.active_agents.iter().any(|agent| agent != "intro"))
+                .map(|(i, _)| i)
+        })
+        .or_else(|| {
+            sessions
+                .iter()
+                .enumerate()
+                .find(|(_, s)| s.status == "busy")
+                .map(|(i, _)| i)
+        })
+        .or_else(|| sessions.first().map(|_| 0))
+}
+
 pub struct CompanionApp {
     state_path: std::path::PathBuf,
     sessions: Vec<SessionInfo>,
     gifs: Gifs,
     rx: Receiver<()>,
     registered: bool,
-    positioned: HashSet<String>,
     size: f32,
     screen: [f32; 2],
     position: String,
     has_modern_config: bool,
+    applied_size: Option<(String, f32, u32, u32)>,
+    applied_position: Option<(String, String)>,
 }
 
 impl CompanionApp {
@@ -112,11 +128,12 @@ impl CompanionApp {
             gifs: Gifs::new(),
             rx,
             registered: false,
-            positioned: HashSet::new(),
             size: initial_size,
             screen: primary_size(),
             position,
             has_modern_config,
+            applied_size: None,
+            applied_position: None,
         }
     }
 
@@ -133,39 +150,17 @@ impl CompanionApp {
             }
         }
 
-        // Liveness check runs every tick, not just on file changes: when an
-        // opencode process is killed it never rewrites the state file, so
-        // waiting on the watcher would leave its window open forever.
         let has_modern = self.has_modern_config;
         self.sessions
             .retain(|s| s.pid.map(is_pid_alive).unwrap_or(!has_modern));
-        let live: HashSet<_> = self.sessions.iter().map(|s| s.session_id.clone()).collect();
-        self.positioned.retain(|id| live.contains(id));
     }
 
-    fn initial_pos(&self, index: usize, win_w: f32, win_h: f32) -> [f32; 2] {
-        let slot = index as f32;
+    fn initial_pos(&self, win_w: f32, win_h: f32) -> [f32; 2] {
         let (x, y) = match self.position.as_str() {
-            "bottom-left" => {
-                let x = GAP + (win_w + GAP) * slot;
-                let y = self.screen[1] - win_h - GAP;
-                (x, y)
-            }
-            "top-right" => {
-                let x = self.screen[0] - (win_w + GAP) * (slot + 1.0);
-                let y = GAP;
-                (x, y)
-            }
-            "top-left" => {
-                let x = GAP + (win_w + GAP) * slot;
-                let y = GAP;
-                (x, y)
-            }
-            _ => { // "bottom-right"
-                let x = self.screen[0] - (win_w + GAP) * (slot + 1.0);
-                let y = self.screen[1] - win_h - GAP;
-                (x, y)
-            }
+            "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);
@@ -194,101 +189,91 @@ impl eframe::App for CompanionApp {
 
         self.size = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
 
-        // Store registered GIF URIs (with unknown agents falling back to the
-        // orchestrator GIF) so cells never point at an unregistered image.
-        let snapshot: SessionSnapshot = self
-            .sessions
-            .iter()
-            .map(|s| {
-                let uris: Vec<String> = if s.active_agents.is_empty() {
-                    vec![self.gifs.uri("intro")]
-                } else {
-                    s.active_agents.iter().map(|a| self.gifs.uri(a)).collect()
-                };
-                (s.session_id.clone(), s.cwd.clone(), uris, s.status.clone())
-            })
-            .collect();
-        ctx.data_mut(|d| d.insert_temp(egui::Id::new(SESSIONS_KEY), snapshot));
-
-        egui::CentralPanel::default()
-            .frame(egui::Frame::none().fill(egui::Color32::TRANSPARENT))
-            .show(ctx, |_| {});
-
-        for (i, session) in self.sessions.iter().enumerate() {
-            let vid = egui::ViewportId::from_hash_of(&session.session_id);
-            let sid = session.session_id.clone();
-            let size = self.size;
+        let Some(selected_idx) = choose_session(&self.sessions) else {
+            egui::CentralPanel::default()
+                .frame(egui::Frame::none().fill(egui::Color32::TRANSPARENT))
+                .show(ctx, |ui| {
+                    ui.centered_and_justified(|ui| {
+                        ui.label("No active sessions");
+                    });
+                });
+            ctx.request_repaint_after(Duration::from_millis(150));
+            return;
+        };
 
-            let agents: Vec<String> = if session.active_agents.is_empty() {
-                vec!["intro".to_string()]
-            } else {
-                session.active_agents.clone()
-            };
-            let n = agents.len().max(1);
-            let (cols, rows) = grid_dims(n);
-            let win_w = size * cols as f32;
-            let win_h = size * rows as f32;
-
-            let is_first = !self.positioned.contains(&session.session_id);
-            if is_first {
-                self.positioned.insert(session.session_id.clone());
-            }
+        let session = self.sessions[selected_idx].clone();
+        let agent_uris: Vec<String> = if session.active_agents.is_empty() {
+            vec![self.gifs.uri("intro")]
+        } else {
+            session
+                .active_agents
+                .iter()
+                .map(|agent| self.gifs.uri(agent))
+                .collect()
+        };
+        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) {
+            ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
+            self.applied_size = Some(size_layout);
+        }
 
-            let mut builder = egui::ViewportBuilder::default()
-                .with_title(&session.project_name())
-                .with_decorations(false)
-                .with_transparent(true)
-                .with_always_on_top()
-                .with_active(false)
-                .with_inner_size([win_w, win_h]);
+        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);
+            ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
+                pos[0], pos[1],
+            )));
+            self.applied_position = Some(position_layout);
+        }
 
-            if is_first {
-                builder = builder.with_position(self.initial_pos(i, win_w, win_h));
-            }
+        if ctx.input(|i| i.pointer.primary_down()) {
+            ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
+        }
 
-            ctx.show_viewport_deferred(vid, builder, move |ctx, _class| {
-                render_session_window(ctx, &sid, size);
+        if ctx.input(|i| i.pointer.secondary_released()) {
+            let cursor = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
+            ctx.data_mut(|d| {
+                d.insert_temp(egui::Id::new(MENU_POS_KEY), [cursor.x, cursor.y]);
+                d.insert_temp(egui::Id::new(MENU_OPEN_KEY), true);
             });
         }
 
-        // Size-picker popup
-        let menu_open: bool =
-            ctx.data(|d| d.get_temp(egui::Id::new(MENU_OPEN_KEY)).unwrap_or(false));
-        if menu_open {
-            let pos: [f32; 2] =
-                ctx.data(|d| d.get_temp(egui::Id::new(MENU_POS_KEY)).unwrap_or([200.0, 200.0]));
-
-            ctx.show_viewport_deferred(
-                egui::ViewportId::from_hash_of("size_picker"),
-                egui::ViewportBuilder::default()
-                    .with_title("size")
-                    .with_decorations(false)
-                    .with_always_on_top()
-                    .with_inner_size([160.0, 190.0])
-                    .with_position(pos),
-                |ctx, _| render_size_picker(ctx),
-            );
-        }
+        egui::CentralPanel::default()
+            .frame(
+                egui::Frame::none()
+                    .fill(egui::Color32::TRANSPARENT)
+                    .inner_margin(egui::Margin::ZERO),
+            )
+            .show(ctx, |ui| {
+                ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
+                render_session(ui, ctx, &session, &agent_uris, self.size, win_w, win_h);
+            });
 
-        ctx.request_repaint_after(Duration::from_millis(150));
+        render_size_picker(ctx);
+        ctx.request_repaint_after(Duration::from_millis(50));
     }
 }
 
-fn render_session_window(ctx: &egui::Context, session_id: &str, _size: f32) {
-    let sessions: SessionSnapshot =
-        ctx.data(|d| d.get_temp(egui::Id::new(SESSIONS_KEY)).unwrap_or_default());
-    let (_, cwd, agents, _) = sessions
-        .iter()
-        .find(|(id, _, _, _)| id == session_id)
-        .cloned()
-        .unwrap_or_default();
-
-    // Snapshot holds ready-to-use GIF URIs.
-    let uris: Vec<String> = if agents.is_empty() {
-        vec!["bytes://intro.gif".to_string()]
-    } else {
-        agents
-    };
+fn render_session(
+    ui: &mut egui::Ui,
+    ctx: &egui::Context,
+    session: &SessionInfo,
+    agent_uris: &[String],
+    current_size: f32,
+    win_w: f32,
+    win_h: f32,
+) {
+    let cwd = &session.cwd;
 
     let project = std::path::Path::new(&cwd)
         .file_name()
@@ -296,165 +281,127 @@ fn render_session_window(ctx: &egui::Context, session_id: &str, _size: f32) {
         .unwrap_or("unknown")
         .to_string();
 
-    let current_size: f32 =
-        ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
-
-    let n = uris.len().max(1);
+    let n = agent_uris.len().max(1);
     let (cols, rows) = grid_dims(n);
-    let win_w = current_size * cols as f32;
-    let win_h = current_size * rows as f32;
-
-    // Resize viewport when size or agent count changes.
-    let layout_key = egui::Id::new(session_id).with("layout");
-    let applied: (u32, u32, u32) = ctx.data(|d| d.get_temp(layout_key).unwrap_or((0, 0, 0)));
-    let current = (current_size as u32, cols as u32, rows as u32);
-    if applied != current {
-        ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
-        ctx.data_mut(|d| d.insert_temp(layout_key, current));
-    }
+    let rects = cell_rects(n, cols, rows, current_size);
 
-    // Right-click → open size picker
-    if ctx.input(|i| i.pointer.secondary_released()) {
-        let win_origin = ctx
-            .input(|i| i.viewport().outer_rect.map(|r| r.min))
-            .unwrap_or_default();
-        let cursor = ctx.input(|i| i.pointer.interact_pos()).unwrap_or_default();
-        ctx.data_mut(|d| {
-            d.insert_temp(
-                egui::Id::new(MENU_POS_KEY),
-                [win_origin.x + cursor.x, win_origin.y + cursor.y],
+    for (i, uri) in agent_uris.iter().enumerate() {
+        if let Some(&cell) = rects.get(i) {
+            ui.put(
+                cell,
+                egui::Image::new(uri).fit_to_exact_size(egui::vec2(current_size, current_size)),
             );
-            d.insert_temp(egui::Id::new(MENU_OPEN_KEY), true);
-        });
-    }
-
-    // Drag
-    if ctx.input(|i| i.pointer.primary_down()) {
-        ctx.send_viewport_cmd(egui::ViewportCommand::StartDrag);
+        }
     }
 
-    egui::CentralPanel::default()
-        .frame(
-            egui::Frame::none()
-                .fill(egui::Color32::TRANSPARENT)
-                .inner_margin(egui::Margin::ZERO),
-        )
-        .show(ctx, |ui| {
-            ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
-            let rects = cell_rects(n, cols, rows, current_size);
-
-            for (i, uri) in uris.iter().enumerate() {
-                if let Some(&cell) = rects.get(i) {
-                    ui.put(cell, egui::Image::new(uri).fit_to_exact_size(egui::vec2(current_size, current_size)));
-                }
-            }
-
-            // Project label — overlaid on bottom strip of the window
-            let label_h = (current_size * 0.15).clamp(13.0, 30.0);
-            let font_size = (current_size * 0.09).clamp(9.0, 13.0);
-            let full_rect = egui::Rect::from_min_size(
-                egui::Pos2::ZERO,
-                egui::vec2(win_w, win_h),
-            );
-            let strip = egui::Rect::from_min_size(
-                egui::pos2(0.0, win_h - label_h),
-                egui::vec2(win_w, label_h),
-            );
-            ui.painter()
-                .rect_filled(strip, 0.0, egui::Color32::from_black_alpha(185));
-
-            let fid = egui::FontId::proportional(font_size);
-            let label = fit_text(ctx, &project, &fid, win_w - 10.0);
-            ui.painter().text(
-                egui::pos2(full_rect.center().x, strip.center().y),
-                egui::Align2::CENTER_CENTER,
-                &label,
-                fid,
-                egui::Color32::WHITE,
-            );
-        });
-
-    ctx.request_repaint_after(Duration::from_millis(50));
+    let label_h = (current_size * 0.15).clamp(13.0, 30.0);
+    let font_size = (current_size * 0.09).clamp(9.0, 13.0);
+    let strip =
+        egui::Rect::from_min_size(egui::pos2(0.0, win_h - label_h), egui::vec2(win_w, label_h));
+    ui.painter()
+        .rect_filled(strip, 0.0, egui::Color32::from_black_alpha(185));
+
+    let fid = egui::FontId::proportional(font_size);
+    let label = fit_text(ctx, &project, &fid, win_w - 10.0);
+    ui.painter().text(
+        strip.center(),
+        egui::Align2::CENTER_CENTER,
+        &label,
+        fid,
+        egui::Color32::WHITE,
+    );
 }
 
 fn render_size_picker(ctx: &egui::Context) {
-    let size: f32 = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
-    let frames_key = egui::Id::new("menu_frames");
-    let frames: u32 = ctx.data(|d| d.get_temp(frames_key).unwrap_or(0));
-    ctx.data_mut(|d| d.insert_temp(frames_key, frames + 1));
-
-    let close = ctx.input(|i| i.key_pressed(egui::Key::Escape))
-        || (frames > 1 && !ctx.input(|i| i.focused));
+    let open: bool = ctx.data(|d| d.get_temp(egui::Id::new(MENU_OPEN_KEY)).unwrap_or(false));
+    if !open {
+        return;
+    }
 
-    if close {
-        ctx.data_mut(|d| {
-            d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
-            d.insert_temp(frames_key, 0u32);
-        });
-        ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+    if ctx.input(|i| i.key_pressed(egui::Key::Escape)) {
+        ctx.data_mut(|d| d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false));
         return;
     }
 
-    egui::CentralPanel::default()
-        .frame(
+    let pos: [f32; 2] = ctx.data(|d| {
+        d.get_temp(egui::Id::new(MENU_POS_KEY))
+            .unwrap_or([20.0, 20.0])
+    });
+    let size: f32 = ctx.data(|d| d.get_temp(egui::Id::new(SIZE_KEY)).unwrap_or(DEFAULT_SIZE));
+
+    let response = egui::Area::new(egui::Id::new("size_picker"))
+        .fixed_pos(egui::pos2(pos[0], pos[1]))
+        .order(egui::Order::Foreground)
+        .show(ctx, |ui| {
             egui::Frame::none()
                 .fill(egui::Color32::from_rgb(28, 28, 28))
-                .inner_margin(egui::Margin::same(8.0)),
-        )
-        .show(ctx, |ui| {
-            ui.label(
-                egui::RichText::new("Window size")
-                    .size(11.0)
-                    .color(egui::Color32::from_rgb(160, 160, 160)),
-            );
-            ui.add_space(4.0);
-
-            for (label, preset) in SIZE_PRESETS {
-                let active = (size - preset).abs() < 0.5;
-                let text = if active {
-                    egui::RichText::new(*label).size(12.0).strong().color(egui::Color32::WHITE)
-                } else {
-                    egui::RichText::new(*label)
-                        .size(12.0)
-                        .color(egui::Color32::from_rgb(200, 200, 200))
-                };
-                if ui
-                    .add_sized([144.0, 20.0], egui::Button::new(text).frame(false))
-                    .clicked()
-                {
-                    ctx.data_mut(|d| {
-                        d.insert_temp(egui::Id::new(SIZE_KEY), *preset);
-                        d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
-                        d.insert_temp(frames_key, 0u32);
-                    });
-                    ctx.send_viewport_cmd(egui::ViewportCommand::Close);
-                }
-            }
-
-            ui.add_space(4.0);
-            ui.separator();
-            ui.add_space(2.0);
-
-            if ui
-                .add_sized(
-                    [144.0, 20.0],
-                    egui::Button::new(
-                        egui::RichText::new("Close companion")
-                            .size(12.0)
-                            .color(egui::Color32::from_rgb(220, 100, 100)),
-                    )
-                    .frame(false),
-                )
-                .clicked()
-            {
-                ctx.data_mut(|d| {
-                    d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
-                    d.insert_temp(frames_key, 0u32);
-                    d.insert_temp(egui::Id::new("companion_quit"), true);
+                .stroke(egui::Stroke::new(1.0, egui::Color32::from_gray(70)))
+                .inner_margin(egui::Margin::same(8.0))
+                .show(ui, |ui| {
+                    ui.label(
+                        egui::RichText::new("Window size")
+                            .size(11.0)
+                            .color(egui::Color32::from_rgb(160, 160, 160)),
+                    );
+                    ui.add_space(4.0);
+
+                    for (label, preset) in SIZE_PRESETS {
+                        let active = (size - preset).abs() < 0.5;
+                        let text = if active {
+                            egui::RichText::new(*label)
+                                .size(12.0)
+                                .strong()
+                                .color(egui::Color32::WHITE)
+                        } else {
+                            egui::RichText::new(*label)
+                                .size(12.0)
+                                .color(egui::Color32::from_rgb(200, 200, 200))
+                        };
+                        if ui
+                            .add_sized([144.0, 20.0], egui::Button::new(text).frame(false))
+                            .clicked()
+                        {
+                            ctx.data_mut(|d| {
+                                d.insert_temp(egui::Id::new(SIZE_KEY), *preset);
+                                d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
+                            });
+                        }
+                    }
+
+                    ui.add_space(4.0);
+                    ui.separator();
+                    ui.add_space(2.0);
+
+                    if ui
+                        .add_sized(
+                            [144.0, 20.0],
+                            egui::Button::new(
+                                egui::RichText::new("Close companion")
+                                    .size(12.0)
+                                    .color(egui::Color32::from_rgb(220, 100, 100)),
+                            )
+                            .frame(false),
+                        )
+                        .clicked()
+                    {
+                        ctx.data_mut(|d| {
+                            d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false);
+                            d.insert_temp(egui::Id::new("companion_quit"), true);
+                        });
+                    }
                 });
-                ctx.send_viewport_cmd(egui::ViewportCommand::Close);
-            }
         });
+
+    let clicked_outside = ctx.input(|i| {
+        (i.pointer.primary_released() || i.pointer.secondary_released())
+            && i.pointer
+                .interact_pos()
+                .map(|pos| !response.response.rect.contains(pos))
+                .unwrap_or(false)
+    });
+    if clicked_outside {
+        ctx.data_mut(|d| d.insert_temp(egui::Id::new(MENU_OPEN_KEY), false));
+    }
 }
 
 fn fit_text(ctx: &egui::Context, text: &str, font_id: &egui::FontId, max_width: f32) -> String {
@@ -496,3 +443,55 @@ fn is_pid_alive(pid: u32) -> bool {
 fn is_pid_alive(_pid: u32) -> bool {
     true
 }
+
+#[cfg(test)]
+mod tests {
+    use super::{choose_session, SessionInfo};
+
+    fn session(id: &str, status: &str, agents: &[&str]) -> SessionInfo {
+        SessionInfo {
+            session_id: id.to_string(),
+            cwd: format!("/{id}"),
+            active_agents: agents.iter().map(|s| s.to_string()).collect(),
+            status: status.to_string(),
+            pid: Some(1),
+            active_agent: None,
+        }
+    }
+
+    #[test]
+    fn waiting_input_wins() {
+        let sessions = vec![
+            session("idle", "idle", &["intro"]),
+            session("waiting", "waiting-input", &["input"]),
+        ];
+        assert_eq!(choose_session(&sessions), Some(1));
+    }
+
+    #[test]
+    fn non_intro_active_agents_win_over_idle_intro() {
+        let sessions = vec![
+            session("idle", "idle", &["intro"]),
+            session("busy-agent", "idle", &["designer"]),
+        ];
+        assert_eq!(choose_session(&sessions), Some(1));
+    }
+
+    #[test]
+    fn busy_wins_when_no_active_agents() {
+        let sessions = vec![
+            session("idle", "idle", &["intro"]),
+            session("busy", "busy", &[]),
+        ];
+        assert_eq!(choose_session(&sessions), Some(1));
+    }
+
+    #[test]
+    fn falls_back_to_first_retained_session() {
+        let sessions = vec![
+            session("first", "idle", &["intro"]),
+            session("second", "idle", &["intro"]),
+        ];
+        assert_eq!(choose_session(&sessions), Some(0));
+    }
+}

+ 14 - 10
companion/src/gifs.rs

@@ -8,15 +8,15 @@ pub struct Gifs {
 impl Gifs {
     pub fn new() -> Self {
         let mut map: HashMap<&'static str, &'static [u8]> = HashMap::new();
-        map.insert("council",      include_bytes!("../gifs/council.gif"));
-        map.insert("councillor",   include_bytes!("../gifs/council.gif"));
-        map.insert("designer",     include_bytes!("../gifs/designer.gif"));
-        map.insert("explorer",     include_bytes!("../gifs/explorer.gif"));
-        map.insert("fixer",        include_bytes!("../gifs/fixer.gif"));
-        map.insert("input",        include_bytes!("../gifs/question.gif"));
-        map.insert("intro",        include_bytes!("../gifs/intro.gif"));
-        map.insert("librarian",    include_bytes!("../gifs/librarian.gif"));
-        map.insert("oracle",       include_bytes!("../gifs/oracle.gif"));
+        map.insert("council", include_bytes!("../gifs/council.gif"));
+        map.insert("councillor", include_bytes!("../gifs/council.gif"));
+        map.insert("designer", include_bytes!("../gifs/designer.gif"));
+        map.insert("explorer", include_bytes!("../gifs/explorer.gif"));
+        map.insert("fixer", include_bytes!("../gifs/fixer.gif"));
+        map.insert("input", include_bytes!("../gifs/question.gif"));
+        map.insert("intro", include_bytes!("../gifs/intro.gif"));
+        map.insert("librarian", include_bytes!("../gifs/librarian.gif"));
+        map.insert("oracle", include_bytes!("../gifs/oracle.gif"));
         map.insert("orchestrator", include_bytes!("../gifs/orchestrator.gif"));
         Self { map }
     }
@@ -29,7 +29,11 @@ impl Gifs {
     }
 
     pub fn uri(&self, agent: &str) -> String {
-        let name = if self.map.contains_key(agent) { agent } else { "orchestrator" };
+        let name = if self.map.contains_key(agent) {
+            agent
+        } else {
+            "orchestrator"
+        };
         format!("bytes://{name}.gif")
     }
 }

+ 5 - 4
companion/src/main.rs

@@ -16,12 +16,13 @@ fn main() -> eframe::Result {
 
     let options = eframe::NativeOptions {
         viewport: egui::ViewportBuilder::default()
+            .with_title("oh-my-opencode-slim-companion")
+            .with_app_id("oh-my-opencode-slim-companion")
             .with_decorations(false)
             .with_transparent(true)
-            .with_inner_size([1.0, 1.0])
-            // Offscreen so the "coordinator" window is invisible
-            .with_position([-500.0, -500.0])
-            .with_active(false),
+            .with_always_on_top()
+            .with_active(false)
+            .with_inner_size([120.0, 120.0]),
         // Run as a macOS accessory app: no Dock icon, never steals focus
         // from the terminal when the windows appear.
         event_loop_builder: Some(Box::new(|builder| {

+ 9 - 2
companion/src/screen.rs

@@ -6,11 +6,18 @@ pub fn primary_size() -> [f32; 2] {
 #[cfg(target_os = "macos")]
 fn platform_size() -> Option<[f32; 2]> {
     let out = std::process::Command::new("osascript")
-        .args(["-e", "tell application \"Finder\" to get bounds of window of desktop"])
+        .args([
+            "-e",
+            "tell application \"Finder\" to get bounds of window of desktop",
+        ])
         .output()
         .ok()?;
     let s = String::from_utf8(out.stdout).ok()?;
-    let ns: Vec<f32> = s.trim().split(", ").filter_map(|p| p.parse().ok()).collect();
+    let ns: Vec<f32> = s
+        .trim()
+        .split(", ")
+        .filter_map(|p| p.parse().ok())
+        .collect();
     (ns.len() == 4).then(|| [ns[2], ns[3]])
 }
 

+ 0 - 20
companion/src/state.rs

@@ -33,26 +33,6 @@ pub struct SessionInfo {
     pub pid: Option<u32>,
 }
 
-impl SessionInfo {
-    pub fn agents(&self) -> &[String] {
-        if self.active_agents.is_empty() {
-            &[]
-        } else {
-            &self.active_agents
-        }
-    }
-}
-
-impl SessionInfo {
-    pub fn project_name(&self) -> String {
-        std::path::Path::new(&self.cwd)
-            .file_name()
-            .and_then(|n| n.to_str())
-            .unwrap_or("unknown")
-            .to_string()
-    }
-}
-
 pub fn state_file_path() -> PathBuf {
     let base = std::env::var("XDG_DATA_HOME")
         .ok()

+ 42 - 12
docs/companion.md

@@ -37,6 +37,8 @@ During interactive installation, the installer asks whether to download and
 enable the native Companion binary. The prompt defaults to `yes`, so pressing
 Enter installs it.
 
+On niri, the prompt installs normally now that the native companion is fixed.
+
 Companion installation is best-effort. If the binary cannot be downloaded or
 installed, the installer prints a warning and continues installing the core
 plugin without Companion enabled.
@@ -49,6 +51,34 @@ bunx oh-my-opencode-slim install --companion=yes
 
 Pass `--companion=no` to skip the native binary and omit the config block.
 
+## niri support status
+
+The native `companion-v0.1.1` binary now works on niri and exposes a stable
+Wayland app-id/title: `oh-my-opencode-slim-companion`.
+
+niri users who want the Companion to behave like an overlay should add a window
+rule to their niri config, for example:
+
+```kdl
+window-rule {
+    match app-id=r"^oh-my-opencode-slim-companion$"
+    match title=r"^oh-my-opencode-slim-companion$"
+    open-floating true
+    open-focused false
+    default-floating-position x=16 y=16 relative-to="bottom-right"
+}
+```
+
+The rule is optional, but without `open-floating true` niri may tile the
+Companion like any other regular xdg-toplevel window. Adjust the `x`/`y` gap or
+`relative-to` corner if you prefer a different placement.
+
+Run diagnostics with:
+
+```bash
+oh-my-opencode-slim doctor
+```
+
 ---
 
 ## Expected Binary Install Path
@@ -75,7 +105,7 @@ For the desktop companion app, the release workflow follows the V2 distribution
 plan:
 
 1. **GitHub Release Assets**: companion binaries are uploaded to the
-   `companion-v0.1.0` GitHub release.
+   `companion-v0.1.1` GitHub release.
 2. **Separate Companion Versioning**: the companion uses its own version so the
    plugin can ship beta updates without rebuilding native binaries every time.
 3. **OS/Arch Detection**: `--companion=yes` selects the archive for the current
@@ -86,11 +116,11 @@ plan:
 Current release assets are named:
 
 ```text
-oh-my-opencode-slim-companion-v0.1.0-aarch64-apple-darwin.tar.gz
-oh-my-opencode-slim-companion-v0.1.0-x86_64-apple-darwin.tar.gz
-oh-my-opencode-slim-companion-v0.1.0-x86_64-unknown-linux-gnu.tar.gz
-oh-my-opencode-slim-companion-v0.1.0-aarch64-unknown-linux-gnu.tar.gz
-oh-my-opencode-slim-companion-v0.1.0-x86_64-pc-windows-msvc.zip
+oh-my-opencode-slim-companion-v0.1.1-aarch64-apple-darwin.tar.gz
+oh-my-opencode-slim-companion-v0.1.1-x86_64-apple-darwin.tar.gz
+oh-my-opencode-slim-companion-v0.1.1-x86_64-unknown-linux-gnu.tar.gz
+oh-my-opencode-slim-companion-v0.1.1-aarch64-unknown-linux-gnu.tar.gz
+oh-my-opencode-slim-companion-v0.1.1-x86_64-pc-windows-msvc.zip
 ```
 
 Supported installer targets:
@@ -114,13 +144,13 @@ protocol expected by the plugin changes.
 The first companion release is:
 
 ```text
-0.1.0
+0.1.1
 ```
 
 The matching GitHub release tag is:
 
 ```text
-companion-v0.1.0
+companion-v0.1.1
 ```
 
 The installer currently downloads from that tag.
@@ -132,7 +162,7 @@ you are testing the release path:
 
 ```bash
 gh workflow run companion-release.yml \
-  -f version=0.1.0 \
+  -f version=0.1.1 \
   -f targets=macos-arm64
 ```
 
@@ -140,7 +170,7 @@ Build multiple targets by passing a comma-separated list:
 
 ```bash
 gh workflow run companion-release.yml \
-  -f version=0.1.0 \
+  -f version=0.1.1 \
   -f targets=macos-arm64,macos-x64,linux-x64,windows-x64
 ```
 
@@ -161,7 +191,7 @@ the selected archives.
 After the workflow finishes:
 
 ```bash
-gh release view companion-v0.1.0
+gh release view companion-v0.1.1
 ```
 
 Confirm the release contains the archive names expected by the installer for the
@@ -176,7 +206,7 @@ bunx oh-my-opencode-slim@beta install --companion=yes
 ```
 
 The installer detects the user's OS/architecture, downloads the matching archive
-from `companion-v0.1.0`, installs it to the runtime binary path, and writes the
+from `companion-v0.1.1`, installs it to the runtime binary path, and writes the
 companion config block. If the companion install fails, the core plugin install
 continues without enabling Companion.
 

+ 14 - 0
docs/configuration.md

@@ -139,6 +139,20 @@ Presets can also be switched at runtime without restarting using the `/preset` c
 | `companion.position` | string | `"bottom-right"` | The initial corner position of the companion window: `bottom-right`, `bottom-left`, `top-right`, or `top-left` |
 | `companion.size` | string | `"medium"` | The default size preset of the companion window: `small` (80px), `medium` (120px), or `large` (160px) |
 
+> **niri note:** `companion-v0.1.1` is the fixed native companion release.
+> To make it open as a bottom-right overlay, add a niri rule matching its stable
+> `app-id`/title (`oh-my-opencode-slim-companion`), for example:
+>
+> ```kdl
+> window-rule {
+>     match app-id=r"^oh-my-opencode-slim-companion$"
+>     match title=r"^oh-my-opencode-slim-companion$"
+>     open-floating true
+>     open-focused false
+>     default-floating-position x=16 y=16 relative-to="bottom-right"
+> }
+> ```
+
 ### Council configuration note
 
 - The **Council agent model** is configured like any other agent, for example in

+ 2 - 2
src/cli/companion.ts

@@ -12,8 +12,8 @@ import { homedir, tmpdir } from 'node:os';
 import * as path from 'node:path';
 import type { ConfigMergeResult, InstallConfig } from './types';
 
-const COMPANION_VERSION = '0.1.0';
-const COMPANION_TAG = 'companion-v0.1.0';
+const COMPANION_VERSION = '0.1.1';
+const COMPANION_TAG = 'companion-v0.1.1';
 const GITHUB_REPO = 'alvinunreal/oh-my-opencode-slim';
 
 export function getCompanionTarget(): string | null {

+ 51 - 0
src/cli/install.test.ts

@@ -0,0 +1,51 @@
+import { afterEach, describe, expect, test } from 'bun:test';
+import { shouldInstallCompanion } from './install';
+import type { InstallConfig } from './types';
+
+const ORIGINAL_ENV = { ...process.env };
+const ORIGINAL_STDIN_IS_TTY = process.stdin.isTTY;
+
+function baseConfig(): InstallConfig {
+  return {
+    hasTmux: false,
+    installCustomSkills: false,
+    reset: false,
+    backgroundSubagents: 'no',
+    companion: 'ask',
+  };
+}
+
+describe('shouldInstallCompanion', () => {
+  afterEach(() => {
+    process.env = { ...ORIGINAL_ENV };
+    Object.defineProperty(process.stdin, 'isTTY', {
+      configurable: true,
+      value: ORIGINAL_STDIN_IS_TTY,
+    });
+  });
+
+  test('dry-run defaults to install on niri', async () => {
+    process.env.NIRI_SOCKET = '/run/user/1000/niri.sock';
+    const config = { ...baseConfig(), dryRun: true };
+
+    await expect(shouldInstallCompanion(config)).resolves.toBe(true);
+    expect(config.companion).toBe('yes');
+  });
+
+  test('explicit companion yes still enables companion on niri', async () => {
+    process.env.XDG_CURRENT_DESKTOP = 'niri';
+    const config = { ...baseConfig(), companion: 'yes' as const };
+
+    await expect(shouldInstallCompanion(config)).resolves.toBe(true);
+  });
+
+  test('dry-run still defaults to install outside niri', async () => {
+    delete process.env.NIRI_SOCKET;
+    delete process.env.XDG_CURRENT_DESKTOP;
+    delete process.env.DESKTOP_SESSION;
+    const config = { ...baseConfig(), dryRun: true };
+
+    await expect(shouldInstallCompanion(config)).resolves.toBe(true);
+    expect(config.companion).toBe('yes');
+  });
+});

+ 3 - 1
src/cli/install.ts

@@ -233,7 +233,9 @@ export async function configureBackgroundSubagents(
 export async function shouldInstallCompanion(
   config: InstallConfig,
 ): Promise<boolean> {
-  if (config.companion === 'yes') return true;
+  if (config.companion === 'yes') {
+    return true;
+  }
   if (config.companion === 'no') return false;
 
   if (config.dryRun) {

+ 69 - 2
src/companion/manager.test.ts

@@ -1,5 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
-import { mkdirSync, readFileSync, rmSync } from 'node:fs';
+import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
 import * as os from 'node:os';
 import * as path from 'node:path';
 import { CompanionManager, stateFilePath } from './manager';
@@ -7,7 +7,6 @@ import { CompanionManager, stateFilePath } from './manager';
 // Point writes at a temp dir so tests don't touch the real state file.
 const TEST_DIR = path.join(os.tmpdir(), `companion-test-${process.pid}`);
 const XDG_DIR = path.join(TEST_DIR, 'xdg');
-
 function readState() {
   return JSON.parse(readFileSync(stateFilePath(), 'utf8'));
 }
@@ -298,4 +297,72 @@ describe('CompanionManager', () => {
     m.onSessionDeleted('ses_a');
     expect(() => readState()).toThrow();
   });
+
+  it('writes state and allows spawn normally', () => {
+    const m = make('test-enabled');
+    m.onLoad();
+
+    expect(readState().sessions[0].session_id).toBe('test-enabled');
+  });
+
+  it('starts companion normally when enabled', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    writeFileSync(
+      stateFilePath(),
+      JSON.stringify({
+        version: 1,
+        sessions: [
+          {
+            session_id: 'test-enabled',
+            cwd: '/old',
+            active_agents: [],
+            status: 'idle',
+            pid: 1,
+          },
+        ],
+        config: { enabled: true, position: 'bottom-right', size: 'medium' },
+      }),
+    );
+
+    const m = make('test-enabled');
+    m.onLoad();
+
+    const state = readState();
+    expect(state.sessions[0].session_id).toBe('test-enabled');
+    expect(state.config.enabled).toBe(true);
+  });
+
+  it('removes disabled session entries on load', () => {
+    mkdirSync(path.dirname(stateFilePath()), { recursive: true });
+    writeFileSync(
+      stateFilePath(),
+      JSON.stringify({
+        version: 1,
+        sessions: [
+          {
+            session_id: 'test-disabled',
+            cwd: '/old',
+            active_agents: ['intro'],
+            status: 'idle',
+            pid: 1,
+          },
+        ],
+        config: { enabled: false, position: 'bottom-right', size: 'medium' },
+      }),
+    );
+
+    const m = new CompanionManager('test-disabled', '/path', {
+      enabled: false,
+      position: 'bottom-right',
+      size: 'medium',
+    });
+    m.onLoad();
+
+    expect(readState().sessions).toEqual([]);
+    expect(readState().config).toEqual({
+      enabled: false,
+      position: 'bottom-right',
+      size: 'medium',
+    });
+  });
 });