Selaa lähdekoodia

fix: stabilize companion multi-session windows

Alvin Unreal 1 kuukausi sitten
vanhempi
sitoutus
96da6d41d5

BIN
companion/gifs/council.gif


BIN
companion/gifs/designer.gif


BIN
companion/gifs/explorer.gif


BIN
companion/gifs/fixer.gif


BIN
companion/gifs/intro.gif


BIN
companion/gifs/librarian.gif


BIN
companion/gifs/oracle.gif


BIN
companion/gifs/orchestrator.gif


BIN
companion/gifs/question.gif


+ 171 - 8
companion/src/app.rs

@@ -47,6 +47,9 @@ struct WindowGeometryKey {
 struct ConfigKey {
     position: String,
     size: String,
+    gif_pack: String,
+    loop_style: String,
+    speed_bits: u32,
 }
 
 fn grid_cols(n: usize) -> usize {
@@ -78,16 +81,54 @@ fn config_key(config: Option<&CompanionConfigState>) -> Option<ConfigKey> {
     config.map(|cfg| ConfigKey {
         position: cfg.position.clone(),
         size: cfg.size.clone(),
+        gif_pack: normalized_gif_pack(&cfg.gif_pack).to_string(),
+        loop_style: normalized_loop_style(&cfg.loop_style).to_string(),
+        speed_bits: normalized_speed(cfg.speed).to_bits(),
     })
 }
 
-fn apply_config(key: Option<&ConfigKey>, position: &mut String, size: &mut f32) {
+fn normalized_gif_pack(pack: &str) -> &str {
+    match pack {
+        "default" => "default",
+        _ => "default",
+    }
+}
+
+fn normalized_loop_style(style: &str) -> &str {
+    match style {
+        "smooth" => "smooth",
+        _ => "classic",
+    }
+}
+
+fn normalized_speed(speed: f32) -> f32 {
+    if speed.is_finite() {
+        speed.clamp(0.25, 4.0)
+    } else {
+        1.0
+    }
+}
+
+fn apply_config(
+    key: Option<&ConfigKey>,
+    position: &mut String,
+    size: &mut f32,
+    gif_pack: &mut String,
+    loop_style: &mut String,
+    speed: &mut f32,
+) {
     if let Some(cfg) = key {
         *position = cfg.position.clone();
         *size = size_from_config(&cfg.size);
+        *gif_pack = cfg.gif_pack.clone();
+        *loop_style = cfg.loop_style.clone();
+        *speed = f32::from_bits(cfg.speed_bits);
     } else {
         *position = "bottom-right".to_string();
         *size = DEFAULT_SIZE;
+        *gif_pack = "default".to_string();
+        *loop_style = "classic".to_string();
+        *speed = 1.0;
     }
 }
 
@@ -127,6 +168,23 @@ fn restore_window_position(pos: [f32; 2], screen: [f32; 2], win: [f32; 2]) -> [f
     }
 }
 
+fn stack_window_position(
+    position: [f32; 2],
+    anchor: &str,
+    rank: usize,
+    screen: [f32; 2],
+    win: [f32; 2],
+) -> [f32; 2] {
+    let offset = (rank.min(8) as f32) * 18.0;
+    let stacked = match anchor {
+        "bottom-left" => [position[0] + offset, position[1] - offset],
+        "top-right" => [position[0] - offset, position[1] + offset],
+        "top-left" => [position[0] + offset, position[1] + offset],
+        _ => [position[0] - offset, position[1] - offset],
+    };
+    clamp_window_position(stacked, screen, win)
+}
+
 fn canonical_project_key(cwd: &str) -> String {
     std::path::Path::new(cwd)
         .canonicalize()
@@ -167,12 +225,14 @@ fn choose_session(sessions: &[SessionInfo]) -> Option<usize> {
     sessions
         .iter()
         .enumerate()
+        .rev()
         .find(|(_, s)| s.status == "waiting-input")
         .map(|(i, _)| i)
         .or_else(|| {
             sessions
                 .iter()
                 .enumerate()
+                .rev()
                 .find(|(_, s)| s.active_agents.iter().any(|agent| agent != "intro"))
                 .map(|(i, _)| i)
         })
@@ -180,24 +240,40 @@ fn choose_session(sessions: &[SessionInfo]) -> Option<usize> {
             sessions
                 .iter()
                 .enumerate()
+                .rev()
                 .find(|(_, s)| s.status == "busy")
                 .map(|(i, _)| i)
         })
-        .or_else(|| sessions.first().map(|_| 0))
+        .or_else(|| sessions.last().map(|_| sessions.len() - 1))
+}
+
+fn choose_owned_session(sessions: &[SessionInfo], owner_session_id: Option<&str>) -> Option<usize> {
+    if let Some(owner_session_id) = owner_session_id {
+        return sessions
+            .iter()
+            .position(|session| session.session_id == owner_session_id);
+    }
+
+    choose_session(sessions)
 }
 
 pub struct CompanionApp {
     state_path: std::path::PathBuf,
+    owner_session_id: Option<String>,
     sessions: Vec<SessionInfo>,
     gifs: Gifs,
     rx: Receiver<()>,
     registered: bool,
     size: f32,
+    gif_pack: String,
+    loop_style: String,
+    speed: f32,
     screen: [f32; 2],
     position: String,
     has_modern_config: bool,
     applied_config: Option<ConfigKey>,
     applied_geometry: Option<WindowGeometryKey>,
+    last_logged_selection: Option<String>,
     window_positions: std::collections::BTreeMap<String, WindowPositionState>,
     project_keys: std::collections::BTreeMap<String, String>,
     drag_project_key: Option<String>,
@@ -207,30 +283,53 @@ pub struct CompanionApp {
 impl CompanionApp {
     pub fn new(_cc: &eframe::CreationContext<'_>) -> Self {
         let state_path = crate::state::state_file_path();
+        let owner_session_id = std::env::var("OH_MY_OPENCODE_SLIM_COMPANION_SESSION_ID")
+            .ok()
+            .filter(|session_id| !session_id.trim().is_empty());
         let state = read_state(&state_path);
+        crate::log::debug(format!(
+            "app new owner={:?} initial_sessions={}",
+            owner_session_id,
+            state.sessions.len()
+        ));
         let sessions = state.sessions;
         let window_positions = state.window_positions;
 
         let mut initial_size = DEFAULT_SIZE;
         let mut position = "bottom-right".to_string();
+        let mut gif_pack = "default".to_string();
+        let mut loop_style = "classic".to_string();
+        let mut speed = 1.0;
         let has_modern_config = state.config.is_some();
         let applied_config = config_key(state.config.as_ref());
-        apply_config(applied_config.as_ref(), &mut position, &mut initial_size);
+        apply_config(
+            applied_config.as_ref(),
+            &mut position,
+            &mut initial_size,
+            &mut gif_pack,
+            &mut loop_style,
+            &mut speed,
+        );
 
         let rx = start_watcher(state_path.clone());
 
         Self {
             state_path,
+            owner_session_id,
             sessions,
             gifs: Gifs::new(),
             rx,
             registered: false,
             size: initial_size,
+            gif_pack,
+            loop_style,
+            speed,
             screen: primary_size(),
             position,
             has_modern_config,
             applied_config,
             applied_geometry: None,
+            last_logged_selection: None,
             window_positions,
             project_keys: std::collections::BTreeMap::new(),
             drag_project_key: None,
@@ -243,6 +342,12 @@ impl CompanionApp {
             while self.rx.try_recv().is_ok() {}
             let state = read_state(&self.state_path);
             self.sessions = state.sessions;
+            crate::log::debug(format!(
+                "state update owner={:?} sessions={} config={:?}",
+                self.owner_session_id,
+                self.sessions.len(),
+                state.config
+            ));
             self.window_positions = state.window_positions;
             self.project_keys
                 .retain(|cwd, _| self.sessions.iter().any(|session| &session.cwd == cwd));
@@ -250,7 +355,14 @@ impl CompanionApp {
             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);
+                apply_config(
+                    next_config.as_ref(),
+                    &mut self.position,
+                    &mut self.size,
+                    &mut self.gif_pack,
+                    &mut self.loop_style,
+                    &mut self.speed,
+                );
                 self.applied_config = next_config;
             }
             return config_changed;
@@ -306,7 +418,18 @@ impl eframe::App for CompanionApp {
 
         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 {
+        let Some(selected_idx) =
+            choose_owned_session(&self.sessions, self.owner_session_id.as_deref())
+        else {
+            if self.owner_session_id.is_some() {
+                crate::log::debug(format!(
+                    "close owner session missing owner={:?} sessions={}",
+                    self.owner_session_id,
+                    self.sessions.len()
+                ));
+                ctx.send_viewport_cmd(egui::ViewportCommand::Close);
+                return;
+            }
             egui::CentralPanel::default()
                 .frame(egui::Frame::none().fill(egui::Color32::TRANSPARENT))
                 .show(ctx, |ui| {
@@ -319,15 +442,31 @@ impl eframe::App for CompanionApp {
         };
 
         let session = self.sessions[selected_idx].clone();
+        let selection_log_key = format!(
+            "{}|{}|{}|{:?}",
+            session.session_id, session.cwd, session.status, session.active_agents
+        );
+        if self.last_logged_selection.as_ref() != Some(&selection_log_key) {
+            crate::log::debug(format!(
+                "selected owner={:?} idx={} session_id={} cwd={} status={} agents={:?}",
+                self.owner_session_id,
+                selected_idx,
+                session.session_id,
+                session.cwd,
+                session.status,
+                session.active_agents
+            ));
+            self.last_logged_selection = Some(selection_log_key);
+        }
         let project_key = self.project_key_for(&session.cwd);
         let saved_position = self.window_positions.get(&project_key).copied();
         let agent_uris: Vec<String> = if session.active_agents.is_empty() {
-            vec![self.gifs.uri("intro")]
+            vec![self.gifs.uri("intro", &self.gif_pack)]
         } else {
             session
                 .active_agents
                 .iter()
-                .map(|agent| self.gifs.uri(agent))
+                .map(|agent| self.gifs.uri(agent, &self.gif_pack))
                 .collect()
         };
         let n = agent_uris.len().max(1);
@@ -350,7 +489,26 @@ impl eframe::App for CompanionApp {
             ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(egui::vec2(win_w, win_h)));
             let pos = saved_position
                 .map(|pos| restore_window_position([pos.x, pos.y], self.screen, [win_w, win_h]))
-                .unwrap_or_else(|| place_window(&self.position, self.screen, [win_w, win_h]));
+                .unwrap_or_else(|| {
+                    stack_window_position(
+                        place_window(&self.position, self.screen, [win_w, win_h]),
+                        &self.position,
+                        selected_idx,
+                        self.screen,
+                        [win_w, win_h],
+                    )
+                });
+            crate::log::debug(format!(
+                "geometry owner={:?} session_id={} saved_position={:?} pos={:?} win=({}, {}) screen={:?} selected_idx={}",
+                self.owner_session_id,
+                session.session_id,
+                saved_position,
+                pos,
+                win_w,
+                win_h,
+                self.screen,
+                selected_idx
+            ));
             ctx.send_viewport_cmd(egui::ViewportCommand::OuterPosition(egui::pos2(
                 pos[0], pos[1],
             )));
@@ -401,6 +559,11 @@ impl eframe::App for CompanionApp {
             )
             .show(ctx, |ui| {
                 ui.spacing_mut().item_spacing = egui::Vec2::ZERO;
+                // egui_extras' GIF image loader controls playback from the GIF
+                // bytes, so speed and loopStyle are currently plumbed through
+                // state for future support but not applied at render time.
+                let _speed = self.speed;
+                let _loop_style = &self.loop_style;
                 render_session(ui, ctx, &session, &agent_uris, self.size, win_w, win_h);
             });
 

+ 2 - 2
companion/src/gifs.rs

@@ -28,8 +28,8 @@ impl Gifs {
         }
     }
 
-    pub fn uri(&self, agent: &str) -> String {
-        let name = if self.map.contains_key(agent) {
+    pub fn uri(&self, agent: &str, gif_pack: &str) -> String {
+        let name = if gif_pack == "default" && self.map.contains_key(agent) {
             agent
         } else {
             "orchestrator"

+ 41 - 0
companion/src/log.rs

@@ -0,0 +1,41 @@
+use std::io::Write;
+use std::path::PathBuf;
+
+fn log_path() -> PathBuf {
+    let base = std::env::var("XDG_DATA_HOME")
+        .ok()
+        .filter(|s| !s.is_empty())
+        .map(PathBuf::from)
+        .unwrap_or_else(|| {
+            dirs::home_dir()
+                .unwrap_or_else(|| PathBuf::from("."))
+                .join(".local")
+                .join("share")
+        });
+    base.join("opencode").join("log").join(format!(
+        "oh-my-opencode-slim-companion.{}.log",
+        std::process::id()
+    ))
+}
+
+pub fn debug(message: impl AsRef<str>) {
+    if std::env::var("OH_MY_OPENCODE_SLIM_COMPANION_DEBUG")
+        .ok()
+        .as_deref()
+        != Some("1")
+    {
+        return;
+    }
+
+    let path = log_path();
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
+    if let Ok(mut file) = std::fs::OpenOptions::new()
+        .create(true)
+        .append(true)
+        .open(path)
+    {
+        let _ = writeln!(file, "{}", message.as_ref());
+    }
+}

+ 23 - 4
companion/src/main.rs

@@ -2,16 +2,35 @@
 
 mod app;
 mod gifs;
+mod log;
 mod niri;
 mod screen;
 mod singleton;
 mod state;
 
-use singleton::acquire;
-
 fn main() -> eframe::Result {
-    // Exit immediately if another instance is already running
-    if !acquire() {
+    let Some(owner_session_id) = std::env::var("OH_MY_OPENCODE_SLIM_COMPANION_SESSION_ID")
+        .ok()
+        .filter(|session_id| !session_id.trim().is_empty())
+    else {
+        log::debug(format!(
+            "exit missing owner_session_id pid={}",
+            std::process::id()
+        ));
+        return Ok(());
+    };
+
+    log::debug(format!(
+        "start pid={} owner_session_id={}",
+        std::process::id(),
+        owner_session_id
+    ));
+
+    if !singleton::acquire(&owner_session_id) {
+        log::debug(format!(
+            "exit duplicate owner_session_id={}",
+            owner_session_id
+        ));
         return Ok(());
     }
 

+ 66 - 10
companion/src/singleton.rs

@@ -1,6 +1,16 @@
 use std::path::PathBuf;
 
-fn lock_path() -> PathBuf {
+fn lock_path(owner_session_id: &str) -> PathBuf {
+    let safe_owner = owner_session_id
+        .chars()
+        .map(|ch| {
+            if ch.is_ascii_alphanumeric() || ch == '_' || ch == '-' {
+                ch
+            } else {
+                '_'
+            }
+        })
+        .collect::<String>();
     let base = std::env::var("XDG_DATA_HOME")
         .ok()
         .filter(|s| !s.is_empty())
@@ -14,24 +24,70 @@ fn lock_path() -> PathBuf {
     base.join("opencode")
         .join("storage")
         .join("oh-my-opencode-slim")
-        .join("companion.pid")
+        .join(format!("companion.{safe_owner}.pid"))
 }
 
 /// Returns true if this process should continue running.
-/// Returns false if another companion instance is already alive.
-pub fn acquire() -> bool {
-    let path = lock_path();
+/// Returns false if another companion instance for the same OpenCode session is
+/// already alive.
+pub fn acquire(owner_session_id: &str) -> bool {
+    let path = lock_path(owner_session_id);
+    if let Some(parent) = path.parent() {
+        let _ = std::fs::create_dir_all(parent);
+    }
 
-    if let Ok(content) = std::fs::read_to_string(&path) {
-        if let Ok(pid) = content.trim().parse::<u32>() {
-            if pid != std::process::id() && is_alive(pid) {
+    for _ in 0..2 {
+        match std::fs::OpenOptions::new()
+            .write(true)
+            .create_new(true)
+            .open(&path)
+        {
+            Ok(mut file) => {
+                use std::io::Write;
+                let _ = write!(file, "{}", std::process::id());
+                crate::log::debug(format!(
+                    "lock acquired owner_session_id={} pid={} path={}",
+                    owner_session_id,
+                    std::process::id(),
+                    path.display()
+                ));
+                return true;
+            }
+            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
+                let existing_pid = std::fs::read_to_string(&path)
+                    .ok()
+                    .and_then(|content| content.trim().parse::<u32>().ok());
+                if existing_pid.is_some_and(|pid| pid != std::process::id() && is_alive(pid)) {
+                    crate::log::debug(format!(
+                        "lock duplicate owner_session_id={} existing_pid={:?} current_pid={}",
+                        owner_session_id,
+                        existing_pid,
+                        std::process::id()
+                    ));
+                    return false;
+                }
+                crate::log::debug(format!(
+                    "lock stale owner_session_id={} existing_pid={:?} current_pid={} path={}",
+                    owner_session_id,
+                    existing_pid,
+                    std::process::id(),
+                    path.display()
+                ));
+                let _ = std::fs::remove_file(&path);
+            }
+            Err(err) => {
+                crate::log::debug(format!(
+                    "lock error owner_session_id={} err={} path={}",
+                    owner_session_id,
+                    err,
+                    path.display()
+                ));
                 return false;
             }
         }
     }
 
-    let _ = std::fs::write(&path, std::process::id().to_string());
-    true
+    false
 }
 
 #[cfg(unix)]

+ 18 - 0
companion/src/state.rs

@@ -11,6 +11,24 @@ pub struct CompanionConfigState {
     pub enabled: bool,
     pub position: String,
     pub size: String,
+    #[serde(default = "default_gif_pack", rename = "gifPack")]
+    pub gif_pack: String,
+    #[serde(default = "default_loop_style", rename = "loopStyle")]
+    pub loop_style: String,
+    #[serde(default = "default_speed")]
+    pub speed: f32,
+}
+
+fn default_gif_pack() -> String {
+    "default".to_string()
+}
+
+fn default_loop_style() -> String {
+    "classic".to_string()
+}
+
+fn default_speed() -> f32 {
+    1.0
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, Default)]

+ 23 - 2
docs/companion.md

@@ -12,12 +12,16 @@ You can enable the companion by adding a `companion` section to your setting con
     "enabled": true,
     "binaryPath": "/path/to/oh-my-opencode-slim-companion",
     "position": "bottom-right",
-    "size": "medium"
+    "size": "medium",
+    "gifPack": "default",
+    "loopStyle": "classic",
+    "speed": 1,
+    "debug": false
   }
 }
 ```
 
-### Supported Position & Size Values
+### Supported Values
 
 - **`companion.position`**:
   - `bottom-right` (default)
@@ -30,6 +34,23 @@ You can enable the companion by adding a `companion` section to your setting con
   - `medium` (120px) (default)
   - `large` (160px)
 
+- **`companion.gifPack`**:
+  - `default` (default) — the bundled companion GIF set.
+
+- **`companion.loopStyle`**:
+  - `classic` (default) — normal GIF playback.
+  - `smooth` — intended forward-then-backward ping-pong playback for smoother
+    transitions using the same selected GIF pack.
+
+- **`companion.speed`**: optional GIF playback speed multiplier from `0.25` to
+  `4`. The default is `1`. This setting is stored in companion state for
+  compatibility with speed-capable companion renderers; the current native
+  renderer follows the timing embedded in each GIF file.
+
+- **`companion.debug`**: set to `true` to enable verbose native companion debug
+  logs while troubleshooting window/session behavior. Logs are written under
+  `$XDG_DATA_HOME/opencode/log/` or `~/.local/share/opencode/log/`.
+
 - **`companion.binaryPath`**: optional path to a custom companion binary. When
   set, the runtime launches this binary instead of the default install path.
 

+ 25 - 0
oh-my-opencode-slim.schema.json

@@ -437,6 +437,31 @@
             "medium",
             "large"
           ]
+        },
+        "gifPack": {
+          "description": "Bundled companion GIF pack to use.",
+          "type": "string",
+          "enum": [
+            "default"
+          ]
+        },
+        "loopStyle": {
+          "description": "Companion GIF playback style: classic loops or smooth ping-pong playback.",
+          "type": "string",
+          "enum": [
+            "classic",
+            "smooth"
+          ]
+        },
+        "speed": {
+          "description": "Companion GIF playback speed multiplier.",
+          "type": "number",
+          "minimum": 0.25,
+          "maximum": 4
+        },
+        "debug": {
+          "description": "Enable verbose native companion debug logs.",
+          "type": "boolean"
         }
       }
     },

+ 4 - 0
src/cli/providers.ts

@@ -150,6 +150,10 @@ export function generateLiteConfig(
       enabled: true,
       position: 'bottom-right',
       size: 'medium',
+      gifPack: 'default',
+      loopStyle: 'classic',
+      speed: 1,
+      debug: false,
     };
   }
 

+ 13 - 1
src/companion/manager.test.ts

@@ -266,14 +266,22 @@ describe('CompanionManager', () => {
       enabled: true,
       position: 'bottom-right',
       size: 'medium',
+      gifPack: 'default',
+      loopStyle: 'classic',
+      speed: 1,
+      debug: false,
     });
   });
 
-  it('supports custom position and size', () => {
+  it('supports custom position, size, and GIF settings', () => {
     const m = make('test-custom', '/path', {
       enabled: true,
       position: 'top-left',
       size: 'large',
+      gifPack: 'default',
+      loopStyle: 'smooth',
+      speed: 1.5,
+      debug: true,
     });
     m.onLoad();
     const state = readState();
@@ -281,6 +289,10 @@ describe('CompanionManager', () => {
       enabled: true,
       position: 'top-left',
       size: 'large',
+      gifPack: 'default',
+      loopStyle: 'smooth',
+      speed: 1.5,
+      debug: true,
     });
   });
 

+ 27 - 2
src/companion/manager.ts

@@ -28,6 +28,10 @@ interface CompanionState {
     enabled: boolean;
     position: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
     size: 'small' | 'medium' | 'large';
+    gifPack: 'default';
+    loopStyle: 'classic' | 'smooth';
+    speed: number;
+    debug: boolean;
   };
 }
 
@@ -254,6 +258,10 @@ export class CompanionManager {
             enabled: this.config.enabled ?? false,
             position: this.config.position ?? 'bottom-right',
             size: this.config.size ?? 'medium',
+            gifPack: this.config.gifPack ?? 'default',
+            loopStyle: this.config.loopStyle ?? 'classic',
+            speed: this.config.speed ?? 1,
+            debug: this.config.debug ?? false,
           };
         }
       });
@@ -273,9 +281,26 @@ export class CompanionManager {
       return;
     }
     try {
-      const child = spawn(bin, [], { detached: true, stdio: 'ignore' });
+      const child = spawn(bin, [], {
+        detached: true,
+        env: {
+          ...process.env,
+          OH_MY_OPENCODE_SLIM_COMPANION_SESSION_ID: this.id,
+          ...(this.config.debug === true
+            ? { OH_MY_OPENCODE_SLIM_COMPANION_DEBUG: '1' }
+            : {}),
+        },
+        stdio: 'ignore',
+      });
       child.unref();
-      log('[companion] spawned', bin);
+      log(
+        '[companion] spawned',
+        JSON.stringify({
+          bin,
+          sessionId: this.id,
+          debug: this.config.debug === true,
+        }),
+      );
     } catch (err) {
       log('[companion] spawn failed', String(err));
     }

+ 4 - 0
src/config/loader.ts

@@ -325,6 +325,10 @@ export function loadPluginConfig(
       binaryPath: config.companion.binaryPath,
       position: config.companion.position ?? 'bottom-right',
       size: config.companion.size ?? 'medium',
+      gifPack: config.companion.gifPack ?? 'default',
+      loopStyle: config.companion.loopStyle ?? 'classic',
+      speed: config.companion.speed ?? 1,
+      debug: config.companion.debug ?? false,
     };
   }
 

+ 20 - 0
src/config/schema.ts

@@ -198,6 +198,26 @@ export const CompanionConfigSchema = z.object({
     .enum(['bottom-right', 'bottom-left', 'top-right', 'top-left'])
     .optional(),
   size: z.enum(['small', 'medium', 'large']).optional(),
+  gifPack: z
+    .enum(['default'])
+    .optional()
+    .describe('Bundled companion GIF pack to use.'),
+  loopStyle: z
+    .enum(['classic', 'smooth'])
+    .optional()
+    .describe(
+      'Companion GIF playback style: classic loops or smooth ping-pong playback.',
+    ),
+  speed: z
+    .number()
+    .min(0.25)
+    .max(4)
+    .optional()
+    .describe('Companion GIF playback speed multiplier.'),
+  debug: z
+    .boolean()
+    .optional()
+    .describe('Enable verbose native companion debug logs.'),
 });
 
 export type CompanionConfig = z.infer<typeof CompanionConfigSchema>;