omniterm 0.2.27

Web-based tmux terminal manager — one browser tab to watch and drive your AI coding agents
//! CellFrame wire format types (design §9) + Phase 3 diff engine.
//!
//! Phase 1: full-frame JSON encoding via `VtState::encode_cell_frame`.
//! Phase 3: `DiffEngine` for row-level diff (hash → skip unchanged rows).

use alacritty_terminal::term::TermMode;
use alacritty_terminal::vte::ansi::CursorShape;
use serde::Serialize;

// ──────────────────────────────────────────────────────────────
// Wire format types (§9.2, §9.3)
// ──────────────────────────────────────────────────────────────

/// CellFrame JSON message — 前端 renderCellFrame 消费。
#[derive(Serialize)]
pub struct CellFrame {
    pub t: &'static str,
    pub session_id: String,
    pub width: u16,
    pub height: u16,
    pub full: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub cursor: Option<CursorState>,
    pub overlay: bool,
    /// 全帧时 `rows` 长度为 `height`,每元素一行;
    /// diff 帧时 `rows` 仅含变化行,`row_indices` 标注各行在原 grid 中的位置。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub row_indices: Option<Vec<usize>>,
    /// Viewport 窗口帧标记(方案 C Phase 1):携带本帧展示的历史窗口偏移
    /// (行,0 = live 屏)。仅 `viewport_request` 的响应帧携带,常规/overlay
    /// 帧省略——前端据此区分历史帧与实时帧。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub viewport: Option<u32>,
    /// alt-screen 激活标记(方案 C Phase 2,D4):仅 overlay 帧携带(enter/exit
    /// 都发 overlay,无此标记前端无法区分)。viewport 控制器据此在 alt-screen
    /// 期间禁用滚轮接管、并把 wheel 交回 xterm 默认路径。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub alt_screen: Option<bool>,
    /// bracketed paste 模式标记(2026-09-06):所有帧携带,取编码时刻
    /// `mode().contains(TermMode::BRACKETED_PASTE)`。前端据此同步 xterm 的
    /// decPrivateModes——cell_frame 模式下 raw 流不转发,TUI 发的 ?2004h
    /// 前端永远收不到,不同步则多行粘贴被 TUI 逐行当 Enter 提交。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub bracketed_paste: Option<bool>,
    /// 鼠标上报跟踪模式(2026-09-23):所有帧携带,取编码时刻
    /// `mouse_mode_str(mode())`——`none`/`press`/`drag`/`motion` 对应
    /// DECSET 关闭/1000/1002/1003。cell_frame 模式下 raw 流不转发,TUI 发的
    /// 鼠标上报 DECSET 前端永远收不到,不同步则 xterm `mouseTrackingMode`
    /// 恒 `none`,wheel 不编成鼠标上报发给 TUI——opencode 等 TUI 滚轮完全
    /// 失效。与 `bracketed_paste` 同族(模式 9 家族第三例,见
    /// `docs/dev/debug-patterns/terminal-pty.md`)。**多实现边界(AGENTS §8)**:
    /// alacritty 0.26 不跟踪 DECSET 9(X10)与 1015(urxvt 编码),出现时按
    /// 「未开启」降级(`none`)。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mouse_mode: Option<&'static str>,
    /// 鼠标上报编码(2026-09-23):所有帧携带,取编码时刻
    /// `mouse_encoding_str(mode())`——`default`/`utf8`(DECSET 1005)/`sgr`
    /// (DECSET 1006),后设覆盖前设。前端按此向 xterm 写 DECSET 同步编码
    /// (xterm 6.0 `IModes` 无 encoding 读口,前端按最后写入值记账)。
    /// DECSET 9/1015 未被 alacritty 跟踪,同按「未开启」降级(见
    /// `mouse_mode`)。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub mouse_encoding: Option<&'static str>,
    /// 当前 grid 历史行数(`grid.history_size()`)。所有帧都携带:前端在
    /// viewport 模式下靠它把「距底偏移 y」换算成绝对锚点,新输出推高历史
    /// 时按锚点重算 y,使用户看到的行保持不变(真实终端 scrollback 语义)。
    /// 无此字段前端只能停在上翻时刻的快照(新输出完全不可见)。
    pub history_size: u32,
    /// 帧序号(2026-09-08 增量同步加固 A2):仅 live 编码路径
    /// `encode_cell_frame` 携带,会话级(`VtState`)单调递增、重连不清零。
    /// 前端校验 `seq == lastSeq + 1`,断链(并发连接偷 diff 基线的直接
    /// 信号、后端重启归零)即主动 resync。viewport/overlay 帧不占 diff
    /// 基线、无断链语义,省略此字段——前端对无 seq 帧跳过检测。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub seq: Option<u64>,
    pub rows: Vec<RowData>,
}

/// 一行的线格负载:行内 RLE —— 按 sgr 合并连续字符的扁平数组
/// `[sgr, text, sgr, text, ...]`(`docs/dev/plans/archive/2026-08-28-pty-frame-rle.md` D1/D5)。
///
/// 这是 cell_frame 协议的**唯一**行编码:逐 cell 的 `cells` 格式随该计划
/// P3 的 D4 一并移除,故不存在格式协商(前端无需按字段分派)。
#[derive(Serialize)]
pub struct RowData {
    pub runs: Vec<String>,
}

#[derive(Serialize)]
pub struct CursorState {
    pub row: i32,
    pub col: u16,
    /// DECSCUSR 形状码(0=blink-block, 1=blink-block, 2=steady-block,
    /// 3=blink-under, 4=steady-under, 5=blink-bar, 6=steady-bar)。
    /// 省略时前端保持当前 cursor 形状。
    #[serde(skip_serializing_if = "Option::is_none")]
    pub shape: Option<u8>,
    pub visible: bool,
}

// ──────────────────────────────────────────────────────────────
// DiffEngine (Phase 3)
// ──────────────────────────────────────────────────────────────

/// Row-level diff engine for CellFrame encoding.
///
/// Tracks per-row hashes from the previous frame.  When the calling code
/// produces a **diff frame** (`full: false`), only rows whose hash changed
/// since the last frame are included — the rest are skipped entirely.
///
/// # Invariants
/// - `prev.len() == current grid height` (caller must `resize()` on change).
/// - `invalidate()` forces the next frame to be full (all rows included).
#[derive(Default)]
pub struct DiffEngine {
    prev: Vec<Option<u64>>,
}

impl DiffEngine {
    /// Create engine aligned to a grid of `rows` rows.  First frame is always
    /// treated as a change (no hashes to compare against).
    pub fn with_rows(rows: usize) -> Self {
        Self { prev: vec![None; rows] }
    }

    /// Mark all rows as untracked — the next `changed_rows()` call will
    /// report every row as changed (producing a full frame).
    pub fn invalidate(&mut self) {
        self.prev.fill(None);
    }

    /// Return true when all rows are untracked (first frame after
    /// construction or `invalidate()`) — the next frame should be full.
    pub fn is_untracked(&self) -> bool {
        self.prev.is_empty() || self.prev.iter().all(|h| h.is_none())
    }

    /// Resize to match a new grid height.
    ///
    /// Grows with fresh `None` entries; truncates (extra rows disappear from
    /// the viewport so they don't need tracking).
    pub fn resize(&mut self, new_rows: usize) {
        if new_rows > self.prev.len() {
            self.prev.resize(new_rows, None);
        } else {
            self.prev.truncate(new_rows);
        }
    }

    /// Compare pre-computed per-row hashes against the previous frame.
    ///
    /// Takes a borrowed slice so the caller can compute hashes with a
    /// separate `&self` borrow (avoids `&self` / `&mut self` conflict).
    pub fn changed_rows_from(&mut self, hashes: &[u64]) -> Vec<usize> {
        let mut changed = Vec::new();
        for (i, &h) in hashes.iter().enumerate() {
            if let Some(entry) = self.prev.get(i) {
                if *entry != Some(h) {
                    changed.push(i);
                    if let Some(slot) = self.prev.get_mut(i) {
                        *slot = Some(h);
                    }
                }
            } else {
                // More hash rows than tracked: new rows (after resize extension).
                changed.push(i);
                if i < self.prev.len() {
                    self.prev[i] = Some(h);
                }
            }
        }
        changed
    }
}

// ──────────────────────────────────────────────────────────────
// Helpers
// ──────────────────────────────────────────────────────────────

/// Map alacritty `CursorShape` to DECSCUSR shape code (0-6).
/// Hidden shape maps to 0 (block) because `visible` flag takes precedence.
#[inline]
pub fn decscusr_code(shape: CursorShape) -> u8 {
    match shape {
        CursorShape::Block => 0,
        CursorShape::Underline => 3,
        CursorShape::Beam => 5,
        CursorShape::Hidden => 0,
        CursorShape::HollowBlock => 0,
    }
}

/// 鼠标跟踪模式 → wire 值(TermMode 三位互斥,见 alacritty set_private_mode)。
pub fn mouse_mode_str(mode: TermMode) -> &'static str {
    if mode.contains(TermMode::MOUSE_MOTION) {
        "motion"
    } else if mode.contains(TermMode::MOUSE_DRAG) {
        "drag"
    } else if mode.contains(TermMode::MOUSE_REPORT_CLICK) {
        "press"
    } else {
        "none"
    }
}

/// 鼠标上报编码 → wire 值(SGR/UTF8 后设覆盖前设)。
pub fn mouse_encoding_str(mode: TermMode) -> &'static str {
    if mode.contains(TermMode::SGR_MOUSE) {
        "sgr"
    } else if mode.contains(TermMode::UTF8_MOUSE) {
        "utf8"
    } else {
        "default"
    }
}