Browse Source

Merge branch 'pr-542'

Alvin Unreal 1 month ago
parent
commit
cdeff9c972

+ 1 - 1
companion/Cargo.lock

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

+ 1 - 1
companion/Cargo.toml

@@ -1,6 +1,6 @@
 [package]
 [package]
 name = "oh-my-opencode-slim-companion"
 name = "oh-my-opencode-slim-companion"
-version = "0.1.0"
+version = "0.1.1"
 edition = "2021"
 edition = "2021"
 description = "Desktop companion for oh-my-opencode-slim — shows active agent GIFs per session"
 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::sync::mpsc::Receiver;
 use std::time::Duration;
 use std::time::Duration;
 
 
@@ -18,15 +17,10 @@ const SIZE_PRESETS: &[(&str, f32)] = &[
     ("XL  ·  200px", 200.0),
     ("XL  ·  200px", 200.0),
 ];
 ];
 
 
-const SESSIONS_KEY: &str = "companion_sessions";
 const SIZE_KEY: &str = "companion_size";
 const SIZE_KEY: &str = "companion_size";
 const MENU_OPEN_KEY: &str = "companion_menu_open";
 const MENU_OPEN_KEY: &str = "companion_menu_open";
 const MENU_POS_KEY: &str = "companion_menu_pos";
 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 {
 fn grid_cols(n: usize) -> usize {
     match n {
     match n {
         0 | 1 => 1,
         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) {
 fn grid_dims(n: usize) -> (usize, usize) {
     let n = n.max(1);
     let n = n.max(1);
     let cols = grid_cols(n);
     let cols = grid_cols(n);
@@ -43,7 +36,6 @@ fn grid_dims(n: usize) -> (usize, usize) {
     (cols, rows)
     (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> {
 fn cell_rects(agents: usize, cols: usize, rows: usize, cell: f32) -> Vec<egui::Rect> {
     let mut rects = Vec::with_capacity(agents);
     let mut rects = Vec::with_capacity(agents);
     let full_rows = agents / cols;
     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
     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 {
 pub struct CompanionApp {
     state_path: std::path::PathBuf,
     state_path: std::path::PathBuf,
     sessions: Vec<SessionInfo>,
     sessions: Vec<SessionInfo>,
     gifs: Gifs,
     gifs: Gifs,
     rx: Receiver<()>,
     rx: Receiver<()>,
     registered: bool,
     registered: bool,
-    positioned: HashSet<String>,
     size: f32,
     size: f32,
     screen: [f32; 2],
     screen: [f32; 2],
     position: String,
     position: String,
     has_modern_config: bool,
     has_modern_config: bool,
+    applied_size: Option<(String, f32, u32, u32)>,
+    applied_position: Option<(String, String)>,
 }
 }
 
 
 impl CompanionApp {
 impl CompanionApp {
@@ -112,11 +128,12 @@ impl CompanionApp {
             gifs: Gifs::new(),
             gifs: Gifs::new(),
             rx,
             rx,
             registered: false,
             registered: false,
-            positioned: HashSet::new(),
             size: initial_size,
             size: initial_size,
             screen: primary_size(),
             screen: primary_size(),
             position,
             position,
             has_modern_config,
             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;
         let has_modern = self.has_modern_config;
         self.sessions
         self.sessions
             .retain(|s| s.pid.map(is_pid_alive).unwrap_or(!has_modern));
             .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() {
         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 x_max = (self.screen[0] - win_w - GAP).max(GAP);
         let y_max = (self.screen[1] - win_h - 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));
         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)
     let project = std::path::Path::new(&cwd)
         .file_name()
         .file_name()
@@ -296,165 +281,127 @@ fn render_session_window(ctx: &egui::Context, session_id: &str, _size: f32) {
         .unwrap_or("unknown")
         .unwrap_or("unknown")
         .to_string();
         .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 (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) {
 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;
         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()
             egui::Frame::none()
                 .fill(egui::Color32::from_rgb(28, 28, 28))
                 .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 {
 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 {
 fn is_pid_alive(_pid: u32) -> bool {
     true
     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 {
 impl Gifs {
     pub fn new() -> Self {
     pub fn new() -> Self {
         let mut map: HashMap<&'static str, &'static [u8]> = HashMap::new();
         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"));
         map.insert("orchestrator", include_bytes!("../gifs/orchestrator.gif"));
         Self { map }
         Self { map }
     }
     }
@@ -29,7 +29,11 @@ impl Gifs {
     }
     }
 
 
     pub fn uri(&self, agent: &str) -> String {
     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")
         format!("bytes://{name}.gif")
     }
     }
 }
 }

+ 5 - 4
companion/src/main.rs

@@ -16,12 +16,13 @@ fn main() -> eframe::Result {
 
 
     let options = eframe::NativeOptions {
     let options = eframe::NativeOptions {
         viewport: egui::ViewportBuilder::default()
         viewport: egui::ViewportBuilder::default()
+            .with_title("oh-my-opencode-slim-companion")
+            .with_app_id("oh-my-opencode-slim-companion")
             .with_decorations(false)
             .with_decorations(false)
             .with_transparent(true)
             .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
         // Run as a macOS accessory app: no Dock icon, never steals focus
         // from the terminal when the windows appear.
         // from the terminal when the windows appear.
         event_loop_builder: Some(Box::new(|builder| {
         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")]
 #[cfg(target_os = "macos")]
 fn platform_size() -> Option<[f32; 2]> {
 fn platform_size() -> Option<[f32; 2]> {
     let out = std::process::Command::new("osascript")
     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()
         .output()
         .ok()?;
         .ok()?;
     let s = String::from_utf8(out.stdout).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]])
     (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>,
     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 {
 pub fn state_file_path() -> PathBuf {
     let base = std::env::var("XDG_DATA_HOME")
     let base = std::env::var("XDG_DATA_HOME")
         .ok()
         .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
 enable the native Companion binary. The prompt defaults to `yes`, so pressing
 Enter installs it.
 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
 Companion installation is best-effort. If the binary cannot be downloaded or
 installed, the installer prints a warning and continues installing the core
 installed, the installer prints a warning and continues installing the core
 plugin without Companion enabled.
 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.
 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
 ## Expected Binary Install Path
@@ -75,7 +105,7 @@ For the desktop companion app, the release workflow follows the V2 distribution
 plan:
 plan:
 
 
 1. **GitHub Release Assets**: companion binaries are uploaded to the
 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
 2. **Separate Companion Versioning**: the companion uses its own version so the
    plugin can ship beta updates without rebuilding native binaries every time.
    plugin can ship beta updates without rebuilding native binaries every time.
 3. **OS/Arch Detection**: `--companion=yes` selects the archive for the current
 3. **OS/Arch Detection**: `--companion=yes` selects the archive for the current
@@ -86,11 +116,11 @@ plan:
 Current release assets are named:
 Current release assets are named:
 
 
 ```text
 ```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:
 Supported installer targets:
@@ -114,13 +144,13 @@ protocol expected by the plugin changes.
 The first companion release is:
 The first companion release is:
 
 
 ```text
 ```text
-0.1.0
+0.1.1
 ```
 ```
 
 
 The matching GitHub release tag is:
 The matching GitHub release tag is:
 
 
 ```text
 ```text
-companion-v0.1.0
+companion-v0.1.1
 ```
 ```
 
 
 The installer currently downloads from that tag.
 The installer currently downloads from that tag.
@@ -132,7 +162,7 @@ you are testing the release path:
 
 
 ```bash
 ```bash
 gh workflow run companion-release.yml \
 gh workflow run companion-release.yml \
-  -f version=0.1.0 \
+  -f version=0.1.1 \
   -f targets=macos-arm64
   -f targets=macos-arm64
 ```
 ```
 
 
@@ -140,7 +170,7 @@ Build multiple targets by passing a comma-separated list:
 
 
 ```bash
 ```bash
 gh workflow run companion-release.yml \
 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
   -f targets=macos-arm64,macos-x64,linux-x64,windows-x64
 ```
 ```
 
 
@@ -161,7 +191,7 @@ the selected archives.
 After the workflow finishes:
 After the workflow finishes:
 
 
 ```bash
 ```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
 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
 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
 companion config block. If the companion install fails, the core plugin install
 continues without enabling Companion.
 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.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) |
 | `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
 ### Council configuration note
 
 
 - The **Council agent model** is configured like any other agent, for example in
 - 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 * as path from 'node:path';
 import type { ConfigMergeResult, InstallConfig } from './types';
 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';
 const GITHUB_REPO = 'alvinunreal/oh-my-opencode-slim';
 
 
 export function getCompanionTarget(): string | null {
 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(
 export async function shouldInstallCompanion(
   config: InstallConfig,
   config: InstallConfig,
 ): Promise<boolean> {
 ): Promise<boolean> {
-  if (config.companion === 'yes') return true;
+  if (config.companion === 'yes') {
+    return true;
+  }
   if (config.companion === 'no') return false;
   if (config.companion === 'no') return false;
 
 
   if (config.dryRun) {
   if (config.dryRun) {

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

@@ -1,5 +1,5 @@
 import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
 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 os from 'node:os';
 import * as path from 'node:path';
 import * as path from 'node:path';
 import { CompanionManager, stateFilePath } from './manager';
 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.
 // 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 TEST_DIR = path.join(os.tmpdir(), `companion-test-${process.pid}`);
 const XDG_DIR = path.join(TEST_DIR, 'xdg');
 const XDG_DIR = path.join(TEST_DIR, 'xdg');
-
 function readState() {
 function readState() {
   return JSON.parse(readFileSync(stateFilePath(), 'utf8'));
   return JSON.parse(readFileSync(stateFilePath(), 'utf8'));
 }
 }
@@ -298,4 +297,72 @@ describe('CompanionManager', () => {
     m.onSessionDeleted('ses_a');
     m.onSessionDeleted('ses_a');
     expect(() => readState()).toThrow();
     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',
+    });
+  });
 });
 });