crates/signaltty-core/src/model.rs

//! Server → Workspace → Tab → Pane model. See docs/02.

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};

use crate::error::CoreError;
use crate::state::{Attention, Lifecycle};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct PtySize {
    pub cols: u16,
    pub rows: u16,
}

impl PtySize {
    pub const MIN_COLS: u16 = 20;
    pub const MAX_COLS: u16 = 500;
    pub const MIN_ROWS: u16 = 5;
    pub const MAX_ROWS: u16 = 200;

    pub fn new(cols: u16, rows: u16) -> Result<PtySize, CoreError> {
        if !(Self::MIN_COLS..=Self::MAX_COLS).contains(&cols)
            || !(Self::MIN_ROWS..=Self::MAX_ROWS).contains(&rows)
        {
            return Err(CoreError::BadSize(format!("{cols}x{rows} out of range")));
        }
        Ok(PtySize { cols, rows })
    }

    /// Clamp client-requested sizes into range (resize arbitration).
    pub fn clamp(cols: u16, rows: u16) -> PtySize {
        PtySize {
            cols: cols.clamp(Self::MIN_COLS, Self::MAX_COLS),
            rows: rows.clamp(Self::MIN_ROWS, Self::MAX_ROWS),
        }
    }
}

impl Default for PtySize {
    fn default() -> Self {
        PtySize { cols: 80, rows: 24 }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SplitDir {
    Right,
    Down,
}

/// Binary split tree. The server stores it; the GUI renders it.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Layout {
    Pane {
        pane_id: String,
    },
    Split {
        dir: SplitDir,
        ratio: f32,
        first: Box<Layout>,
        second: Box<Layout>,
    },
}

impl Layout {
    /// Hard stops for divider ratios: a client can never collapse a
    /// pane entirely, whatever pixels it reports.
    pub const MIN_RATIO: f32 = 0.05;
    pub const MAX_RATIO: f32 = 0.95;

    /// Clamp a divider ratio into range. Callers must reject
    /// non-finite input first (`clamp` panics on NaN).
    pub fn clamp_ratio(ratio: f32) -> f32 {
        ratio.clamp(Self::MIN_RATIO, Self::MAX_RATIO)
    }

    /// Validate leaf uniqueness and finite ratios, then normalize dividers.
    pub fn validate_and_normalize(&mut self) -> Result<(), CoreError> {
        fn validate(
            layout: &Layout,
            seen: &mut std::collections::HashSet<String>,
        ) -> Result<(), CoreError> {
            match layout {
                Layout::Pane { pane_id } => {
                    if !seen.insert(pane_id.clone()) {
                        return Err(CoreError::BadLayout(format!("duplicate pane {pane_id}")));
                    }
                }
                Layout::Split {
                    ratio,
                    first,
                    second,
                    ..
                } => {
                    if !ratio.is_finite() {
                        return Err(CoreError::BadLayout("ratio must be finite".into()));
                    }
                    validate(first, seen)?;
                    validate(second, seen)?;
                }
            }
            Ok(())
        }
        fn normalize(layout: &mut Layout) {
            if let Layout::Split {
                ratio,
                first,
                second,
                ..
            } = layout
            {
                *ratio = Layout::clamp_ratio(*ratio);
                normalize(first);
                normalize(second);
            }
        }
        validate(self, &mut std::collections::HashSet::new())?;
        normalize(self);
        Ok(())
    }

    /// Replace the leaf holding `pane_id` with a split containing the old
    /// pane and `new_pane_id`, then divide the run evenly: every visual
    /// sibling in the same direction ends up with the same width (or
    /// height). Returns false if `pane_id` was not found.
    pub fn split(&mut self, pane_id: &str, dir: SplitDir, new_pane_id: String) -> bool {
        let inserted = self.split_at(pane_id, dir, new_pane_id.clone());
        if inserted {
            self.equalize_run(&new_pane_id, dir);
        }
        inserted
    }

    fn split_at(&mut self, pane_id: &str, dir: SplitDir, new_pane_id: String) -> bool {
        match self {
            Layout::Pane { pane_id: id } if id == pane_id => {
                let old = std::mem::replace(
                    self,
                    Layout::Pane {
                        pane_id: String::new(),
                    },
                );
                *self = Layout::Split {
                    dir,
                    ratio: 0.5,
                    first: Box::new(old),
                    second: Box::new(Layout::Pane {
                        pane_id: new_pane_id,
                    }),
                };
                true
            }
            Layout::Pane { .. } => false,
            Layout::Split { first, second, .. } => {
                first.split_at(pane_id, dir, new_pane_id.clone())
                    || second.split_at(pane_id, dir, new_pane_id)
            }
        }
    }

    /// Recompute the ratios on `pane_id`'s run in `dir` so every
    /// column (or row) gets an equal share. The run is the maximal
    /// subtree around the pane whose splits all point in `dir`;
    /// ancestors above it and nested cross-direction splits keep
    /// their ratios. Dragged dividers in the run reset to even.
    fn equalize_run(&mut self, pane_id: &str, dir: SplitDir) {
        let mut path = Vec::new();
        if !self.path_to(pane_id, &mut path) {
            return;
        }
        // The segment starts below the last cross-direction split on
        // the path; without one it is the whole tree.
        let mut depth = 0;
        let mut node = &*self;
        for (i, &side) in path.iter().enumerate() {
            match node {
                Layout::Split {
                    dir: d,
                    first,
                    second,
                    ..
                } => {
                    if *d != dir {
                        depth = i + 1;
                    }
                    node = if side { second } else { first };
                }
                Layout::Pane { .. } => break,
            }
        }
        let mut seg = &mut *self;
        for &side in &path[..depth] {
            match seg {
                Layout::Split { first, second, .. } => {
                    seg = if side { second } else { first };
                }
                Layout::Pane { .. } => return,
            }
        }
        seg.assign_equal(dir);
    }

    /// Rewrite every `dir` ratio in this subtree for equal shares.
    /// Returns the subtree's width in columns (a cross-direction
    /// subtree counts as one column, whatever it holds).
    fn assign_equal(&mut self, dir: SplitDir) -> f32 {
        match self {
            Layout::Split {
                dir: d,
                ratio,
                first,
                second,
            } if *d == dir => {
                let w_first = first.assign_equal(dir);
                let w_second = second.assign_equal(dir);
                *ratio = w_first / (w_first + w_second);
                w_first + w_second
            }
            _ => 1.0,
        }
    }

    /// Child choices from the root to `pane_id` (`false` = first,
    /// `true` = second). False if the pane is not in the tree.
    fn path_to(&self, pane_id: &str, path: &mut Vec<bool>) -> bool {
        match self {
            Layout::Pane { pane_id: id } => id == pane_id,
            Layout::Split { first, second, .. } => {
                path.push(false);
                if first.path_to(pane_id, path) {
                    return true;
                }
                path.pop();
                path.push(true);
                if second.path_to(pane_id, path) {
                    return true;
                }
                path.pop();
                false
            }
        }
    }

    fn node_at(&self, path: &[bool]) -> Option<&Layout> {
        let mut node = self;
        for &side in path {
            match node {
                Layout::Split { first, second, .. } => {
                    node = if side { second } else { first };
                }
                Layout::Pane { .. } => return None,
            }
        }
        Some(node)
    }

    fn node_at_mut(&mut self, path: &[bool]) -> Option<&mut Layout> {
        let mut node = self;
        for &side in path {
            match node {
                Layout::Split { first, second, .. } => {
                    node = if side { second } else { first };
                }
                Layout::Pane { .. } => return None,
            }
        }
        Some(node)
    }

    /// Set the ratio of the split at `path` (empty = root). False
    /// unless the path resolves to a `Split`.
    pub fn set_ratio_at_path(&mut self, path: &[bool], ratio: f32) -> bool {
        match self.node_at_mut(path) {
            Some(Layout::Split { ratio: r, .. }) => {
                *r = ratio;
                true
            }
            _ => false,
        }
    }

    /// Ratio of the split at `path`, if it resolves to a `Split`.
    pub fn ratio_at_path(&self, path: &[bool]) -> Option<f32> {
        match self.node_at(path) {
            Some(Layout::Split { ratio, .. }) => Some(*ratio),
            _ => None,
        }
    }

    /// True when `path` resolves to a `Split` (clients use it to tell
    /// a stale drag from a failed send).
    pub fn has_split_at(&self, path: &[bool]) -> bool {
        self.ratio_at_path(path).is_some()
    }

    /// Same tree ignoring split ratios: panes, directions and nesting
    /// must match. The GUI reconciles widget trees on this so a
    /// dragged divider never rebuilds a terminal.
    pub fn same_structure(&self, other: &Layout) -> bool {
        match (self, other) {
            (Layout::Pane { pane_id: a }, Layout::Pane { pane_id: b }) => a == b,
            (
                Layout::Split {
                    dir: da,
                    first: fa,
                    second: sa,
                    ..
                },
                Layout::Split {
                    dir: db,
                    first: fb,
                    second: sb,
                    ..
                },
            ) => da == db && fa.same_structure(fb) && sa.same_structure(sb),
            _ => false,
        }
    }

    /// Remove the leaf holding `pane_id`, collapsing its parent split.
    /// Returns false if `pane_id` was not found.
    pub fn remove(&mut self, pane_id: &str) -> bool {
        match self {
            Layout::Pane { pane_id: id } => id == pane_id,
            Layout::Split { first, second, .. } => {
                if matches!(**first, Layout::Pane { pane_id: ref id } if id == pane_id)
                    || matches!(**second, Layout::Pane { pane_id: ref id } if id == pane_id)
                {
                    // Collapse: keep the surviving sibling.
                    let survivor = if matches!(**first, Layout::Pane { pane_id: ref id } if id == pane_id)
                    {
                        std::mem::replace(
                            second.as_mut(),
                            Layout::Pane {
                                pane_id: String::new(),
                            },
                        )
                    } else {
                        std::mem::replace(
                            first.as_mut(),
                            Layout::Pane {
                                pane_id: String::new(),
                            },
                        )
                    };
                    *self = survivor;
                    true
                } else {
                    first.remove(pane_id) || second.remove(pane_id)
                }
            }
        }
    }

    pub fn panes(&self) -> Vec<String> {
        let mut out = Vec::new();
        self.collect(&mut out);
        out
    }

    fn collect(&self, out: &mut Vec<String>) {
        match self {
            Layout::Pane { pane_id } => out.push(pane_id.clone()),
            Layout::Split { first, second, .. } => {
                first.collect(out);
                second.collect(out);
            }
        }
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentKind {
    Codex,
    Claude,
    Opencode,
    Cursor,
    Generic,
    #[default]
    None,
}

impl AgentKind {
    pub fn as_str(self) -> &'static str {
        match self {
            AgentKind::Codex => "codex",
            AgentKind::Claude => "claude",
            AgentKind::Opencode => "opencode",
            AgentKind::Cursor => "cursor",
            AgentKind::Generic => "generic",
            AgentKind::None => "none",
        }
    }

    pub fn parse(s: &str) -> Option<AgentKind> {
        Some(match s {
            "codex" => AgentKind::Codex,
            "claude" => AgentKind::Claude,
            "opencode" => AgentKind::Opencode,
            "cursor" => AgentKind::Cursor,
            "generic" => AgentKind::Generic,
            "none" => AgentKind::None,
            _ => return None,
        })
    }
}

/// Adapter-owned native session identity. Persisted; never scraped from
/// terminal text while a better channel exists. See docs/07, docs/09.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct AgentInfo {
    pub kind: AgentKind,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub agent_session_id: Option<String>,
    /// Official adapter-built resume argv. Never auto-run without opt-in.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resume_argv: Option<Vec<String>>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub model: Option<String>,
    /// Non-secret provider config directories retained for an official resume.
    #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")]
    pub config_env: std::collections::HashMap<String, String>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "state", rename_all = "snake_case")]
pub enum LiveState {
    Live,
    Exited { code: Option<i32> },
}

/// Honest post-restart presence. See docs/09.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "UPPERCASE")]
pub enum RestoreState {
    Live,
    Restored,
    Resumable,
    Exited,
}

/// One answerable option of a pending [`Decision`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DecisionOption {
    pub id: String,
    pub label: String,
}

/// A structured decision request from an agent (directive 2): rendered as
/// data-driven buttons, never scraped from terminal text. At most one per
/// pane; newer supersedes older. See `docs/adr/0008-decision-answer.md`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Decision {
    pub id: String,
    pub prompt: String,
    pub options: Vec<DecisionOption>,
    /// Captured at ingest from the adapter's answer channel: buttons iff
    /// true, else a read-only prompt with an answer-in-terminal hint.
    /// Defaults to true for forward-compat payloads predating channels.
    #[serde(default = "crate::model::decision_answerable_default")]
    pub answerable: bool,
    pub received_at: DateTime<Utc>,
}

fn decision_answerable_default() -> bool {
    true
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pane {
    pub id: String,
    pub workspace_id: String,
    pub tab_id: String,
    pub title: String,
    pub cwd: String,
    pub argv: Vec<String>,
    pub pty_size: PtySize,
    pub live: LiveState,
    pub restore_state: RestoreState,
    pub agent: AgentInfo,
    pub lifecycle: Lifecycle,
    pub last_lifecycle: Lifecycle,
    /// When `lifecycle` last changed; drives "Working for 2m…".
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub lifecycle_since: Option<DateTime<Utc>>,
    /// Length of the most recent `working` stretch, in seconds;
    /// drives "Worked for 2m" once the turn ends.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_run_secs: Option<i64>,
    pub attention: Attention,
    /// Latest explicit notification / hook summary. Never a raw scrape.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_message: Option<String>,
    /// Pending structured decision (directive 2). None = plain terminal.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pending_decision: Option<Decision>,
    pub created_at: DateTime<Utc>,
    pub last_activity_at: DateTime<Utc>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_seen_at: Option<DateTime<Utc>>,
}

impl Pane {
    /// A freshly spawned, live pane titled after its command.
    pub fn new(
        workspace_id: String,
        tab_id: String,
        cwd: String,
        argv: Vec<String>,
        pty_size: PtySize,
        now: DateTime<Utc>,
    ) -> Pane {
        let title = argv
            .first()
            .map(|a| {
                std::path::Path::new(a)
                    .file_name()
                    .map(|n| n.to_string_lossy().to_string())
                    .unwrap_or_else(|| a.clone())
            })
            .unwrap_or_else(|| "shell".to_string());
        Pane {
            id: crate::ids::new_pane_id(),
            workspace_id,
            tab_id,
            title,
            cwd,
            argv,
            pty_size,
            live: LiveState::Live,
            restore_state: RestoreState::Live,
            agent: AgentInfo::default(),
            lifecycle: Lifecycle::Unknown,
            last_lifecycle: Lifecycle::Unknown,
            lifecycle_since: Some(now),
            last_run_secs: None,
            attention: Attention::None,
            last_message: None,
            pending_decision: None,
            created_at: now,
            last_activity_at: now,
            last_seen_at: None,
        }
    }

    /// Attention clears only on explicit per-pane interaction.
    pub fn mark_seen(&mut self, now: DateTime<Utc>) {
        self.attention = Attention::None;
        self.last_seen_at = Some(now);
    }

    pub fn raise_attention(&mut self, next: Attention) {
        self.attention = self.attention.raise(next);
    }

    /// Leaving `working` records how long the run took.
    pub fn set_lifecycle(&mut self, next: Lifecycle, now: DateTime<Utc>) {
        if self.lifecycle == next {
            return;
        }
        if self.lifecycle == Lifecycle::Working {
            self.last_run_secs = self.lifecycle_since.map(|t| (now - t).num_seconds().max(0));
        }
        self.last_lifecycle = self.lifecycle;
        self.lifecycle = next;
        self.lifecycle_since = Some(now);
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Tab {
    pub id: String,
    pub workspace_id: String,
    pub title: String,
    /// None = empty tab (no panes yet).
    pub layout: Option<Layout>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub active_pane_id: Option<String>,
    pub created_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GitInfo {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub root: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub name: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch: Option<String>,
    #[serde(default)]
    pub detached: bool,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dirty: Option<bool>,
}

/// Slugify a workspace name into a stable handle: lowercase alphanumerics
/// joined by single `-`, truncated to 32 chars, `ws` fallback. Never
/// contains `_`, so handles and `ws_…` ids stay disjoint by construction.
pub fn slugify(name: &str) -> String {
    let mut out = String::new();
    let mut dash = false;
    for c in name.chars().flat_map(|c| c.to_lowercase()) {
        if c.is_ascii_alphanumeric() {
            out.push(c);
            dash = false;
        } else if !dash {
            out.push('-');
            dash = true;
        }
        if out.len() >= 32 {
            break;
        }
    }
    let slug = out.trim_matches('-').to_string();
    if slug.is_empty() {
        "ws".to_string()
    } else {
        slug
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Workspace {
    pub id: String,
    pub name: String,
    /// Immutable unique handle (`my-api`): the stable typable address.
    /// `name` is the editable label. Backfilled on legacy snapshots.
    #[serde(default)]
    pub handle: String,
    pub cwd: String,
    #[serde(default)]
    pub git: GitInfo,
    pub tabs: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub active_tab_id: Option<String>,
    #[serde(default)]
    pub auto_resume: bool,
    pub created_at: DateTime<Utc>,
    pub updated_at: DateTime<Utc>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum NotificationSeverity {
    Info,
    Warning,
    Error,
}

impl NotificationSeverity {
    pub fn parse(s: &str) -> Option<NotificationSeverity> {
        Some(match s {
            "info" => NotificationSeverity::Info,
            "warning" => NotificationSeverity::Warning,
            "error" => NotificationSeverity::Error,
            _ => return None,
        })
    }

    pub fn attention(self) -> Attention {
        match self {
            NotificationSeverity::Info => Attention::Unread,
            NotificationSeverity::Warning => Attention::Warning,
            NotificationSeverity::Error => Attention::Error,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Notification {
    pub id: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pane_id: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workspace_id: Option<String>,
    pub title: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub body: Option<String>,
    pub severity: NotificationSeverity,
    pub source: String,
    pub created_at: DateTime<Utc>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub read_at: Option<DateTime<Utc>>,
}

#[cfg(test)]
mod tests {
    #[test]
    fn layout_validation_rejects_non_finite_ratios_without_mutation() {
        use super::{Layout, SplitDir};
        for ratio in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
            let mut layout = Layout::Split {
                dir: SplitDir::Right,
                ratio,
                first: Box::new(Layout::Pane {
                    pane_id: "a".into(),
                }),
                second: Box::new(Layout::Pane {
                    pane_id: "b".into(),
                }),
            };
            assert!(layout.validate_and_normalize().is_err());
            assert_eq!(
                layout.ratio_at_path(&[]).unwrap().to_bits(),
                ratio.to_bits()
            );
        }
    }

    use super::*;

    fn pane(id: &str) -> Layout {
        Layout::Pane {
            pane_id: id.to_string(),
        }
    }

    #[test]
    fn layout_split_and_remove() {
        let mut l = pane("a");
        assert!(l.split("a", SplitDir::Right, "b".into()));
        assert_eq!(l.panes(), vec!["a".to_string(), "b".to_string()]);
        assert!(l.split("b", SplitDir::Down, "c".into()));
        assert_eq!(
            l.panes(),
            vec!["a".to_string(), "b".to_string(), "c".to_string()]
        );
        assert!(!l.split("zzz", SplitDir::Right, "d".into()));
        assert!(l.remove("b"));
        assert_eq!(l.panes(), vec!["a".to_string(), "c".to_string()]);
        assert!(!l.remove("zzz"));
    }

    /// Width share of each pane left-to-right, for Right runs.
    fn widths(l: &Layout) -> Vec<f32> {
        fn walk(l: &Layout, scale: f32, out: &mut Vec<f32>) {
            match l {
                Layout::Pane { .. } => out.push(scale),
                Layout::Split {
                    ratio,
                    first,
                    second,
                    ..
                } => {
                    walk(first, scale * ratio, out);
                    walk(second, scale * (1.0 - ratio), out);
                }
            }
        }
        let mut out = Vec::new();
        walk(l, 1.0, &mut out);
        out
    }

    fn assert_widths(l: &Layout, expected: &[f32]) {
        let got = widths(l);
        assert_eq!(got.len(), expected.len(), "widths {got:?}");
        for (g, e) in got.iter().zip(expected) {
            assert!((g - e).abs() < 1e-6, "widths {got:?} != {expected:?}");
        }
    }

    #[test]
    fn split_equalizes_same_direction_run() {
        // Repeatedly splitting the newest pane keeps every column equal.
        let mut l = pane("a");
        assert!(l.split("a", SplitDir::Right, "b".into()));
        assert_widths(&l, &[0.5, 0.5]);
        assert!(l.split("b", SplitDir::Right, "c".into()));
        assert_widths(&l, &[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
        assert!(l.split("c", SplitDir::Right, "d".into()));
        assert_widths(&l, &[0.25, 0.25, 0.25, 0.25]);
    }

    #[test]
    fn split_equalizes_middle_of_run() {
        // Splitting a middle column equalizes the whole run, not just
        // the new pair.
        let mut l = pane("a");
        l.split("a", SplitDir::Right, "b".into());
        l.split("b", SplitDir::Right, "c".into());
        assert!(l.split("a", SplitDir::Right, "d".into()));
        assert_widths(&l, &[0.25, 0.25, 0.25, 0.25]);
        assert_eq!(l.panes(), vec!["a", "d", "b", "c"]);
    }

    #[test]
    fn split_leaves_other_direction_ratios_alone() {
        let mut l = pane("a");
        l.split("a", SplitDir::Right, "b".into());
        l.split("b", SplitDir::Right, "c".into());
        assert!(l.split("c", SplitDir::Down, "d".into()));
        // The Right run still divides in thirds; the Down pair in halves.
        assert_eq!(l.ratio_at_path(&[]), Some(1.0 / 3.0));
        assert_eq!(l.ratio_at_path(&[true]), Some(0.5));
        assert_eq!(l.ratio_at_path(&[true, true]), Some(0.5));
    }

    #[test]
    fn split_resets_dragged_ratios_in_run() {
        let mut l = pane("a");
        l.split("a", SplitDir::Right, "b".into());
        assert!(l.set_ratio_at_path(&[], 0.9));
        assert!(l.split("b", SplitDir::Right, "c".into()));
        assert_widths(&l, &[1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]);
    }

    #[test]
    fn split_equalizes_nested_same_direction_columns() {
        // Side subtrees with their own same-direction splits count all
        // their columns: [X Y] P -> [X Y] [P Q] in quarters.
        let mut l = Layout::Split {
            dir: SplitDir::Right,
            ratio: 0.5,
            first: Box::new(Layout::Split {
                dir: SplitDir::Right,
                ratio: 0.5,
                first: Box::new(pane("x")),
                second: Box::new(pane("y")),
            }),
            second: Box::new(pane("p")),
        };
        assert!(l.split("p", SplitDir::Right, "q".into()));
        assert_widths(&l, &[0.25, 0.25, 0.25, 0.25]);
    }

    #[test]
    fn ratio_at_path_resolves_splits_only() {
        let mut l = pane("a");
        l.split("a", SplitDir::Right, "b".into());
        assert_eq!(l.ratio_at_path(&[]), Some(0.5));
        assert_eq!(l.ratio_at_path(&[false]), None); // leaf, not a split
        assert_eq!(l.ratio_at_path(&[true, false]), None); // past a leaf
        assert!(l.set_ratio_at_path(&[], 0.25));
        assert_eq!(l.ratio_at_path(&[]), Some(0.25));
        assert!(!l.set_ratio_at_path(&[false], 0.25));
        assert!(!l.set_ratio_at_path(&[true, true], 0.25));
        assert!(l.has_split_at(&[]));
        assert!(!l.has_split_at(&[true]));
    }

    #[test]
    fn clamp_ratio_keeps_dividers_sane() {
        assert_eq!(Layout::clamp_ratio(0.5), 0.5);
        assert_eq!(Layout::clamp_ratio(0.0), 0.05);
        assert_eq!(Layout::clamp_ratio(1.0), 0.95);
        assert_eq!(Layout::clamp_ratio(-3.0), 0.05);
    }

    #[test]
    fn same_structure_ignores_ratios_only() {
        let mut a = pane("a");
        a.split("a", SplitDir::Right, "b".into());
        let mut b = a.clone();
        assert!(a.same_structure(&b));
        b.set_ratio_at_path(&[], 0.9);
        assert!(a.same_structure(&b));
        b.split("b", SplitDir::Down, "c".into());
        assert!(!a.same_structure(&b));
    }

    #[test]
    fn pty_size_clamp_and_validate() {
        assert!(PtySize::new(80, 24).is_ok());
        assert!(PtySize::new(1, 24).is_err());
        let s = PtySize::clamp(1000, 1);
        assert_eq!((s.cols, s.rows), (PtySize::MAX_COLS, PtySize::MIN_ROWS));
    }

    #[test]
    fn pane_mark_seen_keeps_lifecycle() {
        let now = Utc::now();
        let mut p = Pane::new(
            "ws_x".into(),
            "tab_x".into(),
            "/tmp".into(),
            vec!["sh".into()],
            PtySize::default(),
            now,
        );
        p.lifecycle = Lifecycle::Done;
        p.attention = Attention::Unread;
        p.mark_seen(now);
        assert_eq!(p.attention, Attention::None);
        assert_eq!(p.lifecycle, Lifecycle::Done);
        assert!(p.last_seen_at.is_some());
    }

    #[test]
    fn pane_new_titles_after_command() {
        let now = Utc::now();
        let p = Pane::new(
            "w".into(),
            "t".into(),
            "/".into(),
            vec!["/usr/bin/claude".into()],
            PtySize::default(),
            now,
        );
        assert_eq!(p.title, "claude");
        let p = Pane::new(
            "w".into(),
            "t".into(),
            "/".into(),
            vec![],
            PtySize::default(),
            now,
        );
        assert_eq!(p.title, "shell");
    }

    #[test]
    fn leaving_working_records_run_length() {
        let t0 = Utc::now();
        let mut p = Pane::new(
            "w".into(),
            "t".into(),
            "/".into(),
            vec!["sh".into()],
            PtySize::default(),
            t0,
        );
        p.set_lifecycle(Lifecycle::Working, t0);
        p.set_lifecycle(Lifecycle::Working, t0 + chrono::Duration::seconds(30));
        assert_eq!(
            p.lifecycle_since,
            Some(t0),
            "no-op transition keeps the clock"
        );
        p.set_lifecycle(Lifecycle::Done, t0 + chrono::Duration::seconds(125));
        assert_eq!(p.last_run_secs, Some(125));
        assert_eq!(p.last_lifecycle, Lifecycle::Working);
        p.set_lifecycle(Lifecycle::Idle, t0 + chrono::Duration::seconds(200));
        assert_eq!(p.last_run_secs, Some(125), "only working stretches count");
    }

    #[test]
    fn slugify_makes_stable_typable_handles() {
        assert_eq!(slugify("My API!!"), "my-api");
        assert_eq!(slugify("  spaced  out  "), "spaced-out");
        assert_eq!(slugify("!!!"), "ws");
        assert_eq!(slugify(""), "ws");
        assert_eq!(slugify("UPPER_snake"), "upper-snake");
        let long = slugify(&"a".repeat(100));
        assert!(long.len() <= 32);
        for s in ["my-api", "ws", &long] {
            assert!(!s.contains('_'), "{s}");
            assert!(!s.starts_with('-') && !s.ends_with('-'), "{s}");
        }
    }

    #[test]
    fn decision_roundtrips_and_defaults_for_old_snapshots() {
        let now = Utc::now();
        let d = Decision {
            id: "d1".into(),
            prompt: "Allow rm -rf /tmp/x?".into(),
            options: vec![
                DecisionOption {
                    id: "once".into(),
                    label: "Once".into(),
                },
                DecisionOption {
                    id: "always".into(),
                    label: "Always".into(),
                },
                DecisionOption {
                    id: "deny".into(),
                    label: "Deny".into(),
                },
            ],
            answerable: false,
            received_at: now,
        };
        let back: Decision = serde_json::from_value(serde_json::to_value(&d).unwrap()).unwrap();
        assert_eq!(back, d);
        // Payloads predating channels stay answerable.
        let legacy: Decision = serde_json::from_value(serde_json::json!({
            "id": "d1", "prompt": "p", "options": [],
            "received_at": now,
        }))
        .unwrap();
        assert!(legacy.answerable);
        // Old pane snapshots load with no pending decision.
        let p = Pane::new(
            "w".into(),
            "t".into(),
            "/".into(),
            vec!["sh".into()],
            PtySize::default(),
            now,
        );
        assert_eq!(p.pending_decision, None);
        let v = serde_json::to_value(&p).unwrap();
        assert!(
            v.get("pending_decision").is_none(),
            "None skips serialization"
        );
        let back: Pane = serde_json::from_value(serde_json::json!({
            "id": p.id, "workspace_id": "w", "tab_id": "t", "title": "sh",
            "cwd": "/", "argv": ["sh"], "pty_size": {"cols": 80, "rows": 24},
            "live": {"state": "live"}, "restore_state": "LIVE",
            "agent": {"kind": "none"}, "lifecycle": "unknown", "last_lifecycle": "unknown",
            "attention": "none", "created_at": now, "last_activity_at": now,
        }))
        .unwrap();
        assert_eq!(back.pending_decision, None);
    }
}