//! Top-level UI state machine.
//!
//! Responsibilities:
//!   * Load the config store and expose sessions to Slint.
//!   * Manage the tab list + per-tab `SessionHandle` map.
//!   * Route Slint callbacks to the right domain module.

use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Instant;

/// Per-terminal state: vt100 parser drives all rendering for both normal
/// (bash) and alt-screen (vim/nano/htop) modes.
///
/// Using vt100 for normal mode too is necessary because readline rewrites the
/// current input line using `\r` + full-line redraw + `\x1b[K` (erase to EOL)
/// whenever the cursor moves. A naive append-only buffer would duplicate the
/// text; vt100 tracks cursor position and overwrites in place correctly.
struct TermBuffer {
    parser: vt100::Parser,
    /// Active find query for this tab ("" = no search).
    find_query: String,
    /// Current theme mode — propagated from the global dark-mode toggle.
    /// Stored here so the event-pump threads can render new output with the
    /// correct palette without needing a window reference.
    is_dark: bool,
    /// Drag selection in ABSOLUTE scrollback coordinates: each endpoint is a
    /// `(combined_row, col)` where `combined_row` indexes the virtual buffer of
    /// `history` lines followed by the live screen rows.  Absolute (rather than
    /// visible-window) coordinates keep the selection pinned to its content
    /// while the view auto-scrolls during a drag, so a top-to-bottom selection
    /// across more than one screen of scrollback copies every line (#18).
    /// `anchor` = where the drag began, `focus` = the moving end.
    sel_anchor: Option<(usize, u16)>,
    sel_focus: Option<(usize, u16)>,
    /// Session scrollback: lines that have scrolled off the top (oldest first).
    history: Vec<Line>,
    /// Previous frame's grid lines, for scroll-off detection.
    prev: Vec<Line>,
    /// Scrollback view offset in lines (0 = live bottom).
    view_offset: usize,
    /// Plain text of the rows currently displayed (drives find + selection).
    displayed_text: Vec<String>,
    /// CSI-scanner state for rewriting HVP (`ESC [ … f`) into CUP (`ESC [ … H`).
    /// vt100 0.15 only implements the `H` final byte, not the equivalent `f`
    /// that btop/htop use for cursor positioning — without this rewrite their
    /// absolute-positioned full-screen output collapses into a scrolling mess.
    /// Kept here so a sequence split across read chunks is still translated.
    csi_state: CsiState,
}

/// Minimal CSI-final-byte rewriter state (persists across read chunks).
#[derive(Clone, Copy, PartialEq)]
enum CsiState {
    /// Normal text.
    Normal,
    /// Saw ESC (0x1b), waiting to see if it starts a CSI (`[`).
    Esc,
    /// Inside a CSI sequence (after `ESC [`), scanning params/intermediates.
    Csi,
}

type TermBuffers = Arc<Mutex<HashMap<String, TermBuffer>>>;

use anyhow::{Context, Result};
use i_slint_backend_winit::WinitWindowAccessor;
use slint::{ComponentHandle, Model, ModelRc, Rgba8Pixel, SharedPixelBuffer, SharedString, VecModel};
use tokio::runtime::Runtime;

use crate::config::{
    AuthMethod, ConfigStore, Secret, Session, SessionKind, DEFAULT_COMMAND_CATEGORY_ID,
};
use crate::i18n::t;
use crate::sftp::{spawn_sftp, SftpHandle};
use crate::ssh::{
    format_mtime, format_size, spawn_session, RemoteEntry, SessionCommand, SessionEvent,
    SessionHandle,
};
use crate::system::{format_bytes_per_sec, format_bytes_short, format_mem_mib};

type SftpHandles = Arc<Mutex<HashMap<String, SftpHandle>>>;
/// Per-tab flag: once the user explicitly navigates via the SFTP tree or
/// toolbar, stop auto-syncing to the terminal's `cd` path.
type SftpManualNav = Arc<Mutex<HashMap<String, bool>>>;

/// Per-tab connection status + latest remote resource sample, used to drive the
/// sidebar for whichever tab is active.  `Arc<Mutex>` because the SSH event-pump
/// threads update it before bouncing to the UI thread.
#[derive(Clone, Default)]
struct TabStatus {
    host: String, // "root@192.168.100.2"
    state: u8,    // 0 = connecting, 1 = connected, 2 = disconnected
    connected_since: Option<Instant>,
    uptime_secs: u64,
    cpu: f32,     // 0.0..1.0
    cpu_cores: u32,
    mem_used_kib: u64,
    mem_total_kib: u64,
    swap_used_kib: u64,
    swap_total_kib: u64,
    /// Latest per-interface rates: (name, rx_bps, tx_bps), busiest first.
    net: Vec<(String, u64, u64)>,
    /// Which interface drives the top sparkline (empty = auto = busiest).
    selected_iface: String,
    /// Ring buffer of the selected interface's total (rx+tx) bytes/sec.
    net_hist: Vec<f32>,
    /// Per-filesystem (mount, available_bytes, total_bytes).
    disks: Vec<(String, u64, u64)>,
    /// Top processes: (resident_memory_bytes, cpu_percent, command).
    processes: Vec<(u64, f32, String)>,
}
type TabStatuses = Arc<Mutex<HashMap<String, TabStatus>>>;
type TransferSeq = Arc<AtomicU64>;

// Slint generates types into this scope.
slint::include_modules!();

/// Number of samples kept for the sparkline.
const NET_HISTORY_LEN: usize = 60;

fn default_max_tabs_for_ui() -> u32 {
    10
}

fn default_terminal_font_size_for_ui() -> u32 {
    13
}

fn default_sftp_font_size_for_ui() -> u32 {
    16
}

fn default_sftp_name_column_width_for_ui() -> u32 {
    180
}

fn default_terminal_opacity_for_ui() -> u32 {
    88
}

fn default_ui_opacity_for_ui() -> u32 {
    72
}

fn load_background_image(path: &str, blur_px: u32) -> slint::Image {
    let path = path.trim();
    if path.is_empty() {
        return slint::Image::default();
    }
    match image::open(path) {
        Ok(mut img) => {
            let blur_px = blur_px.clamp(0, 20);
            if blur_px > 0 {
                img = img.fast_blur(blur_px as f32);
            }
            let rgba = img.to_rgba8();
            let (w, h) = rgba.dimensions();
            let raw = rgba.into_raw();
            let mut pixels = SharedPixelBuffer::<Rgba8Pixel>::new(w, h);
            for (dst, src) in pixels.make_mut_slice().iter_mut().zip(raw.chunks_exact(4)) {
                *dst = Rgba8Pixel::new(src[0], src[1], src[2], src[3]);
            }
            slint::Image::from_rgba8(pixels)
        }
        Err(err) => {
            tracing::warn!("failed to load background image: {err:#}");
            slint::Image::default()
        }
    }
}

fn apply_window_theme(window: &AppWindow, is_dark: bool) {
    use i_slint_backend_winit::winit::window::Theme as WinitTheme;
    window.window().with_winit_window(|ww| {
        ww.set_theme(Some(if is_dark {
            WinitTheme::Dark
        } else {
            WinitTheme::Light
        }));
    });
}

/// Embed the app icon PNG into the binary and set it as the X11 window icon.
///
/// On X11, the taskbar/dock icon for a running window comes from the
/// `_NET_WM_ICON` property, which winit sets via `Window::set_window_icon`.
/// When the app runs as a bare AppImage (or from a plain directory without
/// running install-linux.sh) there is no installed .desktop + icon, so the
/// dock falls back to a generic gear.  This call fixes that for X11 sessions.
///
/// On Wayland the dock icon is resolved by the compositor from the XDG
/// app-id → .desktop file mapping; `set_window_icon` is a no-op there, so
/// Wayland users still need AppImageLauncher or install-linux.sh for the
/// dock icon.  The `icon:` property in app.slint handles the in-title-bar
/// icon on both backends without any runtime work.
///
/// Windows gets its icon from the `.ico` embedded by winresource at link
/// time; macOS from the app bundle — neither path needs runtime decoding.
#[cfg(target_os = "linux")]
fn set_window_icon(window: &AppWindow) {
    use i_slint_backend_winit::winit::window::Icon;
    const ICON_PNG: &[u8] = include_bytes!("../assets/icon@512.png");
    let Ok(img) = image::load_from_memory(ICON_PNG) else { return };
    let rgba = img.into_rgba8();
    let (w, h) = rgba.dimensions();
    let Ok(icon) = Icon::from_rgba(rgba.into_raw(), w, h) else { return };
    window.window().with_winit_window(|ww| ww.set_window_icon(Some(icon)));
}

pub fn run() -> Result<()> {
    // --- Runtime + store -------------------------------------------------
    let runtime = Arc::new(
        Runtime::new().context("failed to start tokio runtime")?,
    );
    let store = Rc::new(RefCell::new(
        ConfigStore::load().context("failed to load config")?,
    ));

    // Per-tab SSH handles (shell only; lives on Slint thread via Rc).
    let handles: Rc<RefCell<HashMap<String, SessionHandle>>> =
        Rc::new(RefCell::new(HashMap::new()));

    // Per-tab SFTP handles — Arc<Mutex> so the event-pump OS thread and the
    // Slint UI thread can both post SftpCommands.
    let sftp_handles: SftpHandles = Arc::new(Mutex::new(HashMap::new()));
    // Once the user navigates manually in the SFTP panel, stop auto-following cd.
    let sftp_manual_nav: SftpManualNav = Arc::new(Mutex::new(HashMap::new()));

    // Per-tab vt100 parsers + history logs (Arc<Mutex> so they can be cloned
    // into the thread that pumps session events into invoke_from_event_loop).
    let bufs: TermBuffers = Arc::new(Mutex::new(HashMap::new()));

    // Last-known terminal pixel dimensions, updated by every terminal-resize
    // callback.  Shared so on_connect_session can pass a sensible initial PTY
    // size to spawn_session before the first resize callback fires.
    // Default: 80 cols × 24 rows (SSH spec minimum).
    let last_term_size: Arc<Mutex<(u32, u32)>> = Arc::new(Mutex::new((80, 24)));

    // --- Build window + models ------------------------------------------
    // Set the Wayland app_id / X11 WM_CLASS *before* the window is created so
    // the Linux desktop shell can match the running window to the installed
    // `meatshell.desktop` entry and show our icon in the dock/taskbar.  (On
    // Windows the icon comes from the embedded .ico, so this is a no-op there.)
    let _ = slint::set_xdg_app_id("meatshell");
    let window = AppWindow::new().context("failed to build Slint window")?;

    // Set the window icon from the PNG embedded in the binary so the dock
    // shows the correct icon even without a system-installed .desktop entry
    // (e.g. AppImage without AppImageLauncher, or plain binary in ~/bin).
    #[cfg(target_os = "linux")]
    set_window_icon(&window);

    // Apply the saved UI language.  The Rust-side flag drives `i18n::t(...)`;
    // `apply_to_slint` selects the bundled `.po` for the static `@tr(...)` text
    // (must run after the first component exists, which it now does).
    crate::i18n::set_language(store.borrow().language());
    crate::i18n::apply_to_slint();
    window.set_lang_en(crate::i18n::is_en());

    // Apply the saved (or system-detected) theme.
    // "dark" / "light" → use that directly; "system" or unset → ask the OS;
    // OS unknown → fall back to dark.
    {
        let is_dark = match store.borrow().theme_pref() {
            "light" => false,
            "dark"  => true,
            _       => match dark_light::detect() {
                dark_light::Mode::Light   => false,
                dark_light::Mode::Dark    => true,
                dark_light::Mode::Default => true, // undetectable → dark
            },
        };
        window.set_dark_mode(is_dark);
        apply_window_theme(&window, is_dark);
    }

    let sessions_model: Rc<VecModel<SessionInfo>> = Rc::new(VecModel::default());
    window.set_sessions(ModelRc::from(sessions_model.clone()));
    let connection_folders_model: Rc<VecModel<ConnectionFolderInfo>> =
        Rc::new(VecModel::default());
    window.set_connection_folders(ModelRc::from(connection_folders_model.clone()));
    let connection_folder_names_model: Rc<VecModel<SharedString>> =
        Rc::new(VecModel::default());
    window.set_connection_folder_names(ModelRc::from(
        connection_folder_names_model.clone(),
    ));
    sync_connection_folders_to_model(
        &store.borrow(),
        &connection_folders_model,
        &connection_folder_names_model,
    );
    sync_sessions_to_model(&store.borrow(), &sessions_model);

    let tabs_model: Rc<VecModel<TabInfo>> = Rc::new(VecModel::default());
    window.set_tabs(ModelRc::from(tabs_model.clone()));
    window.set_active_tab_id("welcome".into());

    let terminals_model: Rc<VecModel<TerminalState>> = Rc::new(VecModel::default());
    window.set_terminals(ModelRc::from(terminals_model.clone()));
    let transfer_seq: TransferSeq = Arc::new(AtomicU64::new(0));

    // Per-tab connection status + remote resources. Local resource sampling is
    // intentionally disabled so idle welcome/disconnected screens stay light.
    let tab_statuses: TabStatuses = Arc::new(Mutex::new(HashMap::new()));

    // --- Wire callbacks --------------------------------------------------
    wire_session_callbacks(
        &window,
        store.clone(),
        sessions_model.clone(),
        connection_folders_model.clone(),
        connection_folder_names_model.clone(),
        tabs_model.clone(),
        terminals_model.clone(),
        handles.clone(),
        bufs.clone(),
        runtime.clone(),
        last_term_size.clone(),
        sftp_handles.clone(),
        sftp_manual_nav.clone(),
        transfer_seq.clone(),
        tab_statuses.clone(),
    );

    // Recompute the sidebar whenever the active tab changes (fired from Slint's
    // `changed active-tab-id`).
    {
        let weak = window.as_weak();
        let statuses = tab_statuses.clone();
        window.on_refresh_sidebar(move || {
            if let Some(w) = weak.upgrade() {
                refresh_sidebar(&w, &statuses);
            }
        });
    }

    // Switch UI language at runtime.  Static `@tr(...)` text updates live via
    // select_bundled_translation; we additionally refresh the Rust-driven
    // dynamic strings (sidebar status + the welcome tab title).
    {
        let weak = window.as_weak();
        let store = store.clone();
        window.on_set_language(move |code| {
            crate::i18n::set_language(&code.to_string());
            {
                let mut s = store.borrow_mut();
                s.set_language(crate::i18n::current_code().to_string());
                let _ = s.save();
            }
            if let Some(w) = weak.upgrade() {
                w.set_lang_en(crate::i18n::is_en());
                w.invoke_refresh_sidebar();
            }
        });
    }

    // Theme toggle: flip dark ↔ light, persist the preference, and re-render
    // every open terminal with the new ANSI palette so historical output is
    // also recoloured (not just new output).
    {
        let weak = window.as_weak();
        let store = store.clone();
        let bufs_theme = bufs.clone();
        window.on_toggle_theme(move || {
            let Some(w) = weak.upgrade() else { return };
            let next_dark = !w.get_dark_mode();
            w.set_dark_mode(next_dark);
            apply_window_theme(&w, next_dark);
            // Propagate new palette to all open terminal buffers.
            {
                let mut map = bufs_theme.lock().unwrap();
                for buf in map.values_mut() {
                    buf.is_dark = next_dark;
                }
            }
            // Re-render every visible terminal so colours update immediately.
            let tab_ids: Vec<String> = {
                let map = bufs_theme.lock().unwrap();
                map.keys().cloned().collect()
            };
            for tid in tab_ids {
                rebuild_tab_display(&w, &bufs_theme, &tid);
            }
            let pref = if next_dark { "dark" } else { "light" };
            let mut s = store.borrow_mut();
            s.set_theme_pref(pref.to_string());
            let _ = s.save();
        });
    }

    // NIC selector: remember the user's choice for the active tab and refresh.
    {
        let weak = window.as_weak();
        let statuses = tab_statuses.clone();
        window.on_select_net_iface(move |iface: SharedString| {
            let Some(w) = weak.upgrade() else { return };
            let active = w.get_active_tab_id().to_string();
            if let Some(st) = statuses.lock().unwrap().get_mut(&active) {
                st.selected_iface = iface.to_string();
                st.net_hist = vec![0.0; NET_HISTORY_LEN]; // reset graph for new NIC
            }
            refresh_sidebar(&w, &statuses);
        });
    }

    // Settings: preset download directory (load + pick + open).
    window.set_download_dir(store.borrow().download_dir().to_string().into());
    window.set_max_tabs_text(store.borrow().max_terminal_tabs().to_string().into());
    window.set_terminal_font_size_text(store.borrow().terminal_font_size().to_string().into());
    window.set_terminal_font_size(store.borrow().terminal_font_size() as f32);
    window.set_sftp_font_size_text(store.borrow().sftp_file_font_size().to_string().into());
    window.set_sftp_file_font_size(store.borrow().sftp_file_font_size() as f32);
    window.set_background_path_text(store.borrow().background_path().to_string().into());
    window.set_background_mode(store.borrow().background_mode() as i32);
    window.set_background_blur_text(store.borrow().background_blur_px().to_string().into());
    window.set_terminal_opacity_text(
        store
            .borrow()
            .terminal_opacity_percent()
            .to_string()
            .into(),
    );
    window.set_terminal_background_opacity(
        store.borrow().terminal_opacity_percent() as f32 / 100.0,
    );
    window.set_ui_opacity_text(store.borrow().ui_opacity_percent().to_string().into());
    window.set_ui_background_opacity(store.borrow().ui_opacity_percent() as f32 / 100.0);
    window.set_ui_debug_ids(store.borrow().ui_debug_ids());
    window.set_background_image(load_background_image(
        store.borrow().background_path(),
        store.borrow().background_blur_px(),
    ));
    window.set_sftp_name_column_width(store.borrow().sftp_name_column_width() as f32);
    window.set_sftp_name_width_manual(store.borrow().sftp_name_column_manual());
    window.set_config_dir_text(
        store
            .borrow()
            .config_dir()
            .to_string_lossy()
            .to_string()
            .into(),
    );
    window.on_debug_click(move |parent, button| {
        let now = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
        tracing::info!("[{now}]点击[{button}/{parent}]");
    });
    {
        let weak = window.as_weak();
        let store = store.clone();
        window.on_pick_download_dir(move || {
            if let Some(folder) = rfd::FileDialog::new().pick_folder() {
                let dir = folder.to_string_lossy().to_string();
                {
                    let mut s = store.borrow_mut();
                    s.set_download_dir(dir.clone());
                    let _ = s.save();
                }
                if let Some(w) = weak.upgrade() {
                    w.set_download_dir(dir.into());
                }
            }
        });
    }
    {
        let weak = window.as_weak();
        window.on_open_download_dir(move || {
            let Some(w) = weak.upgrade() else { return };
            let dir = w.get_download_dir().to_string();
            if dir.is_empty() {
                return;
            }
            #[cfg(windows)]
            {
                let _ = std::process::Command::new("explorer").arg(&dir).spawn();
            }
            #[cfg(not(windows))]
            {
                let _ = std::process::Command::new("xdg-open").arg(&dir).spawn();
            }
        });
    }
    {
        let weak = window.as_weak();
        window.on_app_config_pick_dir(move || {
            let Some(w) = weak.upgrade() else { return };
            let current = w.get_config_dir_text().to_string();
            let mut dialog = rfd::FileDialog::new()
                .set_title(t("选择配置目录", "Choose configuration directory"));
            if !current.trim().is_empty() {
                dialog = dialog.set_directory(current.trim());
            }
            if let Some(folder) = dialog.pick_folder() {
                w.set_config_dir_text(folder.to_string_lossy().to_string().into());
            }
        });
    }
    {
        let weak = window.as_weak();
        let store = store.clone();
        window.on_app_background_pick(move || {
            let Some(w) = weak.upgrade() else { return };
            let current = w.get_background_path_text().to_string();
            let blur_px = w
                .get_background_blur_text()
                .trim()
                .parse::<u32>()
                .unwrap_or(3)
                .clamp(0, 20);
            let mut dialog = rfd::FileDialog::new()
                .set_title(t("选择背景图", "Choose background image"))
                .add_filter("Images", &["png", "jpg", "jpeg", "bmp", "gif", "webp"]);
            if !current.trim().is_empty() {
                if let Some(parent) = Path::new(current.trim()).parent() {
                    dialog = dialog.set_directory(parent);
                }
            }
            if let Some(file) = dialog.pick_file() {
                let path = file.to_string_lossy().to_string();
                let image = load_background_image(&path, blur_px);
                {
                    let mut s = store.borrow_mut();
                    s.set_background_path(path.clone());
                    s.set_background_blur_px(blur_px);
                    if s.background_mode() == 0 {
                        s.set_background_mode(1);
                        w.set_background_mode(1);
                    }
                    if let Err(err) = s.save() {
                        tracing::warn!("failed to save background config: {err:#}");
                    }
                }
                w.set_background_path_text(path.into());
                w.set_background_blur_text(blur_px.to_string().into());
                w.set_background_image(image);
            }
        });
    }
    {
        let weak = window.as_weak();
        let store = store.clone();
        window.on_app_background_clear(move || {
            {
                let mut s = store.borrow_mut();
                s.set_background_path(String::new());
                s.set_background_mode(0);
                if let Err(err) = s.save() {
                    tracing::warn!("failed to save background config: {err:#}");
                }
            }
            if let Some(w) = weak.upgrade() {
                w.set_background_path_text("".into());
                w.set_background_mode(0);
                w.set_background_image(slint::Image::default());
            }
        });
    }
    {
        let weak = window.as_weak();
        let store = store.clone();
        window.on_app_config_save(
            move |max_tabs: SharedString,
                  terminal_font: SharedString,
                  sftp_font: SharedString,
                  background_mode: i32,
                  background_blur: SharedString,
                  terminal_opacity: SharedString,
                  ui_opacity: SharedString,
                  ui_debug_ids: bool,
                  config_dir: SharedString| {
                let parsed = max_tabs
                    .trim()
                    .parse::<u32>()
                    .unwrap_or(default_max_tabs_for_ui());
                let max_tabs = parsed.clamp(1, 50);
                let parsed_terminal_font = terminal_font
                    .trim()
                    .parse::<u32>()
                    .unwrap_or(default_terminal_font_size_for_ui());
                let terminal_font_size = parsed_terminal_font.clamp(10, 28);
                let parsed_font = sftp_font
                    .trim()
                    .parse::<u32>()
                    .unwrap_or(default_sftp_font_size_for_ui());
                let sftp_font_size = parsed_font.clamp(10, 28);
                let background_mode = background_mode.clamp(0, 2) as u8;
                let parsed_blur = background_blur.trim().parse::<u32>().unwrap_or(3);
                let background_blur = parsed_blur.clamp(0, 20);
                let parsed_terminal_opacity = terminal_opacity
                    .trim()
                    .parse::<u32>()
                    .unwrap_or(default_terminal_opacity_for_ui());
                let terminal_opacity = parsed_terminal_opacity.clamp(30, 100);
                let parsed_ui_opacity = ui_opacity
                    .trim()
                    .parse::<u32>()
                    .unwrap_or(default_ui_opacity_for_ui());
                let ui_opacity = parsed_ui_opacity.clamp(30, 100);
                let requested_config_dir = config_dir.trim().to_string();
                let actual_config_dir;
                {
                    let mut s = store.borrow_mut();
                    s.set_max_terminal_tabs(max_tabs);
                    s.set_terminal_font_size(terminal_font_size);
                    s.set_sftp_file_font_size(sftp_font_size);
                    s.set_background_mode(background_mode);
                    s.set_background_blur_px(background_blur);
                    s.set_terminal_opacity_percent(terminal_opacity);
                    s.set_ui_opacity_percent(ui_opacity);
                    s.set_ui_debug_ids(ui_debug_ids);
                    if !requested_config_dir.is_empty() {
                        let current = s.config_dir().to_path_buf();
                        let requested = PathBuf::from(&requested_config_dir);
                        if requested != current {
                            if let Err(err) = s.set_config_dir(requested) {
                                tracing::warn!("failed to set config directory: {err:#}");
                            }
                        }
                    }
                    if let Err(err) = s.save() {
                        tracing::warn!("failed to save app config: {err:#}");
                    }
                    actual_config_dir = s.config_dir().to_string_lossy().to_string();
                }
                if let Some(w) = weak.upgrade() {
                    w.set_max_tabs_text(max_tabs.to_string().into());
                    w.set_terminal_font_size_text(terminal_font_size.to_string().into());
                    w.set_terminal_font_size(terminal_font_size as f32);
                    w.set_sftp_font_size_text(sftp_font_size.to_string().into());
                    w.set_sftp_file_font_size(sftp_font_size as f32);
                    w.set_background_mode(background_mode as i32);
                    w.set_background_blur_text(background_blur.to_string().into());
                    w.set_terminal_opacity_text(terminal_opacity.to_string().into());
                    w.set_terminal_background_opacity(terminal_opacity as f32 / 100.0);
                    w.set_ui_opacity_text(ui_opacity.to_string().into());
                    w.set_ui_background_opacity(ui_opacity as f32 / 100.0);
                    w.set_ui_debug_ids(ui_debug_ids);
                    w.set_background_image(load_background_image(
                        &w.get_background_path_text(),
                        background_blur,
                    ));
                    w.set_config_dir_text(actual_config_dir.into());
                    w.set_config_open(false);
                }
            },
        );
    }
    {
        let store = store.clone();
        window.on_sftp_name_column_width_changed(move |width_px: f32| {
            let width = width_px.round().clamp(96.0, 360.0) as u32;
            let mut s = store.borrow_mut();
            s.set_sftp_name_column_width(width, true);
            if let Err(err) = s.save() {
                tracing::warn!("failed to save SFTP name column width: {err:#}");
            }
        });
    }
    {
        let weak = window.as_weak();
        let store = store.clone();
        window.on_sftp_name_column_auto(move || {
            let width = default_sftp_name_column_width_for_ui();
            {
                let mut s = store.borrow_mut();
                s.set_sftp_name_column_width(width, false);
                if let Err(err) = s.save() {
                    tracing::warn!("failed to save SFTP name column auto mode: {err:#}");
                }
            }
            if let Some(w) = weak.upgrade() {
                w.set_sftp_name_width_manual(false);
                w.set_sftp_name_column_width(width as f32);
            }
        });
    }

    // Transfer records (download/upload progress + history) shown in the popup.
    let transfers_model: Rc<VecModel<TransferInfo>> = Rc::new(VecModel::default());
    window.set_transfers(ModelRc::from(transfers_model.clone()));
    {
        let tm = transfers_model.clone();
        window.on_clear_transfers(move || tm.set_vec(Vec::<TransferInfo>::new()));
    }

    // FinalShell-style custom command manager.
    let command_categories_model: Rc<VecModel<CommandCategory>> = Rc::new(VecModel::default());
    sync_command_categories_to_model(&store.borrow(), &command_categories_model);
    window.set_command_categories(ModelRc::from(command_categories_model.clone()));
    let command_items_model: Rc<VecModel<CommandItem>> = Rc::new(VecModel::default());
    sync_commands_to_model(&store.borrow(), &command_items_model);
    window.set_command_items(ModelRc::from(command_items_model.clone()));
    window.set_command_selected_category(DEFAULT_COMMAND_CATEGORY_ID.into());
    window.set_command_category_draft("默认分类".into());

    // Open-source libraries shown in the About popup.
    {
        let libs: Vec<SharedString> = [
            t("Slint — 图形界面框架 (GUI)", "Slint — GUI framework"),
            t("russh / russh-keys — SSH 协议实现", "russh / russh-keys — SSH protocol"),
            t("russh-sftp — SFTP 文件传输", "russh-sftp — SFTP file transfer"),
            t("ssh-key — SSH 密钥解析", "ssh-key — SSH key parsing"),
            t("tokio — 异步运行时", "tokio — async runtime"),
            t("vt100 — 终端 (VT100/xterm) 解析", "vt100 — terminal (VT100/xterm) parser"),
            t("serde / serde_json — 配置序列化", "serde / serde_json — config serialization"),
            t("arboard — 系统剪贴板", "arboard — system clipboard"),
            t("rfd — 原生文件对话框", "rfd — native file dialogs"),
            t("directories — 配置目录定位", "directories — config dir lookup"),
            t("chrono — 日期时间处理", "chrono — date/time handling"),
            t("uuid — 唯一标识符", "uuid — unique identifiers"),
            t("anyhow / thiserror — 错误处理", "anyhow / thiserror — error handling"),
            t("tracing / tracing-subscriber — 日志", "tracing / tracing-subscriber — logging"),
            t("futures / async-trait — 异步辅助", "futures / async-trait — async helpers"),
            t("rand — 随机数", "rand — randomness"),
            t("winresource — Windows 图标/资源嵌入", "winresource — Windows icon/resource embedding"),
        ]
        .iter()
        .map(|s| (*s).into())
        .collect();
        window.set_about_libs(ModelRc::from(Rc::new(VecModel::from(libs))));
    }

    wire_tab_callbacks(
        &window,
        tabs_model.clone(),
        terminals_model.clone(),
        handles.clone(),
        bufs.clone(),
        sftp_handles.clone(),
        sftp_manual_nav.clone(),
    );
    wire_sftp_callbacks(&window, sftp_handles.clone(), sftp_manual_nav.clone());
    wire_key_input(&window, handles.clone(), bufs.clone(), last_term_size.clone());
    wire_command_callbacks(
        &window,
        store.clone(),
        command_categories_model.clone(),
        command_items_model.clone(),
        handles.clone(),
    );

    if env_flag("MEATSHELL_SMOKE_TERMINAL") {
        install_terminal_smoke(&window, &tabs_model, &terminals_model, &bufs);
    }

    // OS file drag-and-drop → upload to the active session's SFTP directory,
    // but only when the file is dropped over the file-list area.
    {
        use i_slint_backend_winit::winit::event::WindowEvent as WEvent;
        use i_slint_backend_winit::EventResult;
        let weak = window.as_weak();
        let sh = sftp_handles.clone();
        window.window().on_winit_window_event(move |_w, event| {
            if let WEvent::DroppedFile(path) = event {
                if let Some(win) = weak.upgrade() {
                    handle_file_drop(&win, &sh, path.to_string_lossy().to_string());
                }
            }
            EventResult::Propagate
        });
    }

    // Center the window on the primary monitor once it's shown (size is only
    // known after the first frame, so defer via a single-shot timer).
    {
        let weak = window.as_weak();
        slint::Timer::single_shot(std::time::Duration::from_millis(30), move || {
            if let Some(w) = weak.upgrade() {
                center_window(&w);
            }
        });
    }

    window.run().context("event loop exited with error")?;
    Ok(())
}

fn install_terminal_smoke(
    window: &AppWindow,
    tabs_model: &Rc<VecModel<TabInfo>>,
    terminals_model: &Rc<VecModel<TerminalState>>,
    bufs: &TermBuffers,
) {
    let add_terminal = |tab_id: &str, title: &str, status: &str, detail: &str| {
        tabs_model.push(TabInfo {
            id: tab_id.into(),
            title: title.into(),
            kind: "terminal".into(),
            connected: true,
        });

        let heading = format!("meatshell UI smoke {}", title);
        let spans = vec![
            TermSpan {
                cells: heading.chars().count() as i32,
                text: heading.into(),
                fg: slint::Color::from_rgb_u8(0xd4, 0xd4, 0xd4),
                bg: slint::Color::from_argb_u8(0, 0, 0, 0),
                bold: true,
                use_fallback_font: false,
                row: 0,
                col: 0,
            },
            TermSpan {
                cells: detail.chars().count() as i32,
                text: detail.into(),
                fg: slint::Color::from_rgb_u8(0xa8, 0xb3, 0xc7),
                bg: slint::Color::from_argb_u8(0, 0, 0, 0),
                bold: false,
                use_fallback_font: text_uses_cjk_fallback(detail),
                row: 1,
                col: 0,
            },
        ];

        terminals_model.push(TerminalState {
            id: tab_id.into(),
            tab_id: tab_id.into(),
            status: status.into(),
            spans: ModelRc::from(Rc::new(VecModel::from(spans))),
            cursor_row: 2,
            cursor_col: 0,
            rows_used: 3,
            is_alt_screen: false,
            mouse_tracking: false,
            find_matches: ModelRc::from(Rc::new(VecModel::<TermMatch>::default())),
            selection: ModelRc::from(Rc::new(VecModel::<TermMatch>::default())),
            sftp_path: "/".into(),
            sftp_entries: ModelRc::from(Rc::new(VecModel::<SftpEntry>::default())),
            sftp_status: "Smoke mode".into(),
            sftp_loading: false,
            sftp_tree_nodes: ModelRc::from(Rc::new(VecModel::<SftpTreeNode>::default())),
        });

        bufs.lock().unwrap().insert(
            tab_id.to_string(),
            TermBuffer {
                parser: vt100::Parser::new(24, 80, 5000),
                find_query: String::new(),
                is_dark: true,
                sel_anchor: None,
                sel_focus: None,
                history: Vec::new(),
                prev: Vec::new(),
                view_offset: 0,
                displayed_text: Vec::new(),
                csi_state: CsiState::Normal,
            },
        );
    };

    add_terminal(
        "smoke-terminal-a",
        "A",
        "UI smoke terminal A",
        "first tab layout probe",
    );
    add_terminal(
        "smoke-terminal-b",
        "B",
        "UI smoke terminal B",
        "second tab layout probe",
    );

    window.set_active_tab_id("smoke-terminal-b".into());

    // Keep smoke timers alive until they fire; dropping a Timer cancels it.
    let theme_timer = Box::leak(Box::new(slint::Timer::default()));
    {
        let weak = window.as_weak();
        theme_timer.start(
            slint::TimerMode::SingleShot,
            std::time::Duration::from_millis(300),
            move || {
                if let Some(w) = weak.upgrade() {
                    w.invoke_toggle_theme();
                }
            },
        );
    }

    let tab_a_timer = Box::leak(Box::new(slint::Timer::default()));
    {
        let weak = window.as_weak();
        tab_a_timer.start(
            slint::TimerMode::SingleShot,
            std::time::Duration::from_millis(650),
            move || {
                if let Some(w) = weak.upgrade() {
                    w.set_active_tab_id("smoke-terminal-a".into());
                }
            },
        );
    }

    let tab_b_timer = Box::leak(Box::new(slint::Timer::default()));
    {
        let weak = window.as_weak();
        tab_b_timer.start(
            slint::TimerMode::SingleShot,
            std::time::Duration::from_millis(1000),
            move || {
                if let Some(w) = weak.upgrade() {
                    w.set_active_tab_id("smoke-terminal-b".into());
                }
            },
        );
    }

    let quit_timer = Box::leak(Box::new(slint::Timer::default()));
    quit_timer.start(
        slint::TimerMode::SingleShot,
        std::time::Duration::from_millis(1500),
        || {
            if let Err(err) = slint::quit_event_loop() {
                tracing::error!(error = %err, "failed to quit terminal smoke test");
            }
        },
    );
}

fn env_flag(name: &str) -> bool {
    matches!(
        std::env::var(name).as_deref(),
        Ok("1") | Ok("true") | Ok("TRUE") | Ok("yes") | Ok("YES") | Ok("on") | Ok("ON")
    )
}

/// Center the window on the primary monitor's work area (Windows).
#[cfg(windows)]
fn center_window(win: &AppWindow) {
    #[repr(C)]
    struct Rect {
        left: i32,
        top: i32,
        right: i32,
        bottom: i32,
    }
    #[link(name = "user32")]
    extern "system" {
        fn SystemParametersInfoW(action: u32, uiparam: u32, pvparam: *mut Rect, winini: u32) -> i32;
    }
    const SPI_GETWORKAREA: u32 = 0x0030;

    let size = win.window().size(); // physical pixels
    let mut wa = Rect { left: 0, top: 0, right: 0, bottom: 0 };
    let ok = unsafe { SystemParametersInfoW(SPI_GETWORKAREA, 0, &mut wa, 0) };
    if ok == 0 {
        return;
    }
    let area_w = (wa.right - wa.left).max(0) as u32;
    let area_h = (wa.bottom - wa.top).max(0) as u32;
    let x = wa.left + ((area_w.saturating_sub(size.width)) / 2) as i32;
    let y = wa.top + ((area_h.saturating_sub(size.height)) / 2) as i32;
    win.window()
        .set_position(slint::PhysicalPosition::new(x, y));
}

#[cfg(not(windows))]
fn center_window(_win: &AppWindow) {}

/// The active terminal tab's current SFTP directory ("" if unknown).
#[cfg(windows)]
fn active_sftp_path(win: &AppWindow, tab_id: &str) -> String {
    let model = win.get_terminals();
    if let Some(m) = model.as_any().downcast_ref::<VecModel<TerminalState>>() {
        for i in 0..m.row_count() {
            if let Some(row) = m.row_data(i) {
                if row.id.as_str() == tab_id {
                    return row.sftp_path.to_string();
                }
            }
        }
    }
    String::new()
}

/// Current mouse cursor position in physical screen pixels (Windows).
#[cfg(windows)]
fn cursor_pos() -> Option<(i32, i32)> {
    #[repr(C)]
    struct Point {
        x: i32,
        y: i32,
    }
    extern "system" {
        fn GetCursorPos(p: *mut Point) -> i32;
    }
    let mut p = Point { x: 0, y: 0 };
    if unsafe { GetCursorPos(&mut p) } != 0 {
        Some((p.x, p.y))
    } else {
        None
    }
}

/// Handle an OS file drop: if it landed over the SFTP file-list area of the
/// active session tab, upload the file to that tab's current remote directory.
#[cfg(windows)]
fn handle_file_drop(win: &AppWindow, sftp_handles: &SftpHandles, path: String) {
    let active = win.get_active_tab_id().to_string();
    if active == "welcome" {
        return;
    }
    let w = win.window();
    let scale = w.scale_factor().max(0.01);
    let size = w.size(); // physical
    let Some(inner) = w
        .with_winit_window(|ww| ww.inner_position().ok())
        .flatten()
    else {
        return;
    };
    let Some((cx, cy)) = cursor_pos() else {
        return;
    };
    // Drop point in logical client coordinates.
    let client_x = (cx - inner.x) as f32 / scale;
    let client_y = (cy - inner.y) as f32 / scale;
    let w_logical = size.width as f32 / scale;
    let h_logical = size.height as f32 / scale;
    let h_sftp = win.get_sftp_panel_height();

    // File-list box (logical): right of the sidebar(220)+tree(160)+sep(1),
    // below the tools tab bar(30)+SFTP toolbar(30)+header(20)+sep(1),
    // above the status bar(18).
    let zone_left = 381.0_f32;
    let zone_top = h_logical - h_sftp + 81.0;
    let zone_bottom = h_logical - 18.0;
    if client_x < zone_left
        || client_x > w_logical
        || client_y < zone_top
        || client_y > zone_bottom
    {
        return; // dropped outside the file list — ignore
    }

    let dir = active_sftp_path(win, &active);
    if dir.is_empty() {
        return;
    }
    if let Ok(handles) = sftp_handles.lock() {
        if let Some(h) = handles.get(&active) {
            h.upload(path, dir);
        }
    }
}

#[cfg(not(windows))]
fn handle_file_drop(_win: &AppWindow, _sftp_handles: &SftpHandles, _path: String) {}

// ---------------------------------------------------------------------------
// Model helpers
// ---------------------------------------------------------------------------

fn sync_sessions_to_model(store: &ConfigStore, model: &VecModel<SessionInfo>) {
    let mut rows: Vec<SessionInfo> = store
        .sessions()
        .iter()
        .map(|s| {
            let host = if s.kind == SessionKind::Serial {
                s.serial_port.clone()
            } else {
                s.host.clone()
            };
            SessionInfo {
                id: s.id.clone().into(),
                name: s.name.clone().into(),
                group: session_group(&s.group).into(),
                host: host.into(),
                port: s.port as i32,
                user: s.user.clone().into(),
                auth: s.auth.as_str().into(),
                kind: s.kind.as_str().into(),
                last_used: s
                    .last_used
                    .clone()
                    .unwrap_or_else(|| "never".to_string())
                    .into(),
            }
        })
        .collect();
    rows.sort_by(|a, b| {
        a.group
            .as_str()
            .cmp(b.group.as_str())
            .then_with(|| a.name.as_str().cmp(b.name.as_str()))
    });
    model.set_vec(rows);
}

fn sync_connection_folders_to_model(
    store: &ConfigStore,
    folders_model: &VecModel<ConnectionFolderInfo>,
    names_model: &VecModel<SharedString>,
) {
    let folders: Vec<ConnectionFolderInfo> = store
        .connection_folders()
        .iter()
        .map(|name| ConnectionFolderInfo {
            name: name.clone().into(),
            expanded: store.connection_folder_expanded(name),
        })
        .collect();
    let names: Vec<SharedString> = store
        .connection_folders()
        .iter()
        .map(|name| name.clone().into())
        .collect();
    folders_model.set_vec(folders);
    names_model.set_vec(names);
}

fn session_group(group: &str) -> String {
    let g = group.trim();
    if g.is_empty() {
        "Default".to_string()
    } else {
        g.to_string()
    }
}

fn suggested_sftp_name_column_width(entries: &[RemoteEntry], font_size_px: f32) -> f32 {
    let longest = entries
        .iter()
        .map(|entry| {
            entry
                .name
                .chars()
                .map(|ch| if ch.is_ascii() { 1.0 } else { 1.7 })
                .sum::<f32>()
        })
        .fold(4.0, f32::max);
    let font_size = font_size_px.clamp(10.0, 28.0);
    (longest * font_size * 0.62 + 46.0).clamp(96.0, 360.0)
}

fn sync_command_categories_to_model(store: &ConfigStore, model: &VecModel<CommandCategory>) {
    let rows: Vec<CommandCategory> = store
        .command_categories()
        .iter()
        .map(|c| CommandCategory {
            id: c.id.clone().into(),
            name: c.name.clone().into(),
            builtin: c.builtin,
        })
        .collect();
    model.set_vec(rows);
}

fn sync_commands_to_model(store: &ConfigStore, model: &VecModel<CommandItem>) {
    let rows: Vec<CommandItem> = store
        .commands()
        .iter()
        .map(|c| CommandItem {
            id: c.id.clone().into(),
            category_id: c.category_id.clone().into(),
            name: c.name.clone().into(),
            command: c.command.clone().into(),
            append_cr: c.append_cr,
        })
        .collect();
    model.set_vec(rows);
}

// ---------------------------------------------------------------------------
// Session callbacks (welcome page + dialog)
// ---------------------------------------------------------------------------

fn wire_session_callbacks(
    window: &AppWindow,
    store: Rc<RefCell<ConfigStore>>,
    sessions_model: Rc<VecModel<SessionInfo>>,
    connection_folders_model: Rc<VecModel<ConnectionFolderInfo>>,
    connection_folder_names_model: Rc<VecModel<SharedString>>,
    tabs_model: Rc<VecModel<TabInfo>>,
    terminals_model: Rc<VecModel<TerminalState>>,
    handles: Rc<RefCell<HashMap<String, SessionHandle>>>,
    bufs: TermBuffers,
    runtime: Arc<Runtime>,
    last_term_size: Arc<Mutex<(u32, u32)>>,
    sftp_handles: SftpHandles,
    sftp_manual_nav: SftpManualNav,
    transfer_seq: TransferSeq,
    tab_statuses: TabStatuses,
) {
    // New session -> open dialog with blank draft.
    let weak = window.as_weak();
    let store_new_session = store.clone();
    window.on_new_session_clicked(move || {
        if let Some(w) = weak.upgrade() {
            let empty = Session::new_empty();
            let folder = store_new_session
                .borrow()
                .connection_folders()
                .first()
                .cloned()
                .unwrap_or_else(|| "Default".to_string());
            w.set_dialog_id(empty.id.into());
            w.set_dialog_name("".into());
            w.set_dialog_group(folder.into());
            w.set_dialog_host("".into());
            w.set_dialog_port("22".into());
            w.set_dialog_user("root".into());
            w.set_dialog_auth("password".into());
            w.set_dialog_password("".into());
            w.set_dialog_key_path("".into());
            w.set_dialog_proxy("".into());
            w.set_dialog_kind("ssh".into());
            w.set_dialog_serial_port("".into());
            w.set_dialog_baud("115200".into());
            w.set_dialog_data_bits("8".into());
            w.set_dialog_stop_bits("1".into());
            w.set_dialog_parity("none".into());
            w.set_dialog_flow("none".into());
            w.set_dialog_editing(false);
            w.set_dialog_open(true);
        }
    });

    {
        let weak = window.as_weak();
        let store = store.clone();
        let folders_model = connection_folders_model.clone();
        let folder_names_model = connection_folder_names_model.clone();
        window.on_connection_folder_create(move |name: SharedString| {
            let name = name.trim();
            if name.is_empty() {
                if let Some(w) = weak.upgrade() {
                    w.set_folder_error(
                        t("文件夹名称不能为空", "Folder name is required").into(),
                    );
                }
                return;
            }
            let folder = {
                let mut s = store.borrow_mut();
                let folder = s.add_connection_folder(name.to_string());
                if let Err(err) = s.save() {
                    tracing::warn!("failed to save connection folder: {err:#}");
                }
                folder
            };
            sync_connection_folders_to_model(
                &store.borrow(),
                &folders_model,
                &folder_names_model,
            );
            if let Some(w) = weak.upgrade() {
                w.set_dialog_group(folder.into());
                w.set_folder_draft("".into());
                w.set_folder_error("".into());
                w.set_folder_dialog_open(false);
            }
        });
    }

    {
        let weak = window.as_weak();
        let store = store.clone();
        let sessions_model = sessions_model.clone();
        let folders_model = connection_folders_model.clone();
        let folder_names_model = connection_folder_names_model.clone();
        window.on_connection_folder_rename(move |old_name: SharedString, new_name: SharedString| {
            let new_name = new_name.trim();
            if new_name.is_empty() {
                if let Some(w) = weak.upgrade() {
                    w.set_folder_error(t("分类名称不能为空", "Category name is required").into());
                }
                return;
            }
            let folder = {
                let mut s = store.borrow_mut();
                let folder = s.rename_connection_folder(old_name.as_str(), new_name.to_string());
                if let Err(err) = s.save() {
                    tracing::warn!("failed to rename connection folder: {err:#}");
                }
                folder
            };
            sync_connection_folders_to_model(
                &store.borrow(),
                &folders_model,
                &folder_names_model,
            );
            sync_sessions_to_model(&store.borrow(), &sessions_model);
            if let Some(w) = weak.upgrade() {
                w.set_dialog_group(folder.into());
                w.set_folder_draft("".into());
                w.set_folder_error("".into());
                w.set_folder_dialog_open(false);
            }
        });
    }

    {
        let store = store.clone();
        let sessions_model = sessions_model.clone();
        let folders_model = connection_folders_model.clone();
        let folder_names_model = connection_folder_names_model.clone();
        window.on_connection_folder_delete(move |name: SharedString| {
            {
                let mut s = store.borrow_mut();
                s.remove_connection_folder(name.as_str());
                if let Err(err) = s.save() {
                    tracing::warn!("failed to delete connection folder: {err:#}");
                }
            }
            sync_connection_folders_to_model(
                &store.borrow(),
                &folders_model,
                &folder_names_model,
            );
            sync_sessions_to_model(&store.borrow(), &sessions_model);
        });
    }

    {
        let store = store.clone();
        let folders_model = connection_folders_model.clone();
        let folder_names_model = connection_folder_names_model.clone();
        window.on_connection_folder_toggle(move |name: SharedString| {
            {
                let mut s = store.borrow_mut();
                s.toggle_connection_folder(name.as_str());
                if let Err(err) = s.save() {
                    tracing::warn!("failed to save connection folder state: {err:#}");
                }
            }
            sync_connection_folders_to_model(
                &store.borrow(),
                &folders_model,
                &folder_names_model,
            );
        });
    }

    {
        let store = store.clone();
        let sessions_model = sessions_model.clone();
        window.on_session_move_folder(move |id: SharedString, folder: SharedString| {
            {
                let mut s = store.borrow_mut();
                if s.move_session_to_folder(id.as_str(), folder.as_str()).is_some() {
                    if let Err(err) = s.save() {
                        tracing::warn!("failed to move session to folder: {err:#}");
                    }
                }
            }
            sync_sessions_to_model(&store.borrow(), &sessions_model);
        });
    }

    // Import hosts from ~/.ssh/config -> add them as sessions (skipping dups).
    {
        let weak = window.as_weak();
        let store = store.clone();
        let sessions_model = sessions_model.clone();
        let folders_model = connection_folders_model.clone();
        let folder_names_model = connection_folder_names_model.clone();
        window.on_import_ssh_config(move || {
            let hosts = crate::ssh_config::parse_default();
            let mut added = 0usize;
            if hosts.is_empty() {
                if let Some(w) = weak.upgrade() {
                    w.set_ssh_import_hint(t("未找到 ~/.ssh/config", "no ~/.ssh/config found").into());
                }
                return;
            }
            {
                let mut s = store.borrow_mut();
                let import_folder = s.add_connection_folder("SSH Config".to_string());
                for h in hosts {
                    // Skip if a session already has this alias, or the same
                    // host + user pair.
                    let dup = s.sessions().iter().any(|x| {
                        x.name == h.alias || (x.host == h.hostname && x.user == h.user)
                    });
                    if dup {
                        continue;
                    }
                    let auth = if h.identity_file.is_empty() {
                        AuthMethod::Password
                    } else {
                        AuthMethod::Key
                    };
                    s.upsert(Session {
                        name: h.alias,
                        host: h.hostname,
                        port: h.port,
                        user: if h.user.is_empty() { "root".into() } else { h.user },
                        group: import_folder.clone(),
                        auth,
                        private_key_path: h.identity_file,
                        ..Session::new_empty()
                    });
                    added += 1;
                }
                if added > 0 {
                    let _ = s.save();
                }
            }
            sync_connection_folders_to_model(&store.borrow(), &folders_model, &folder_names_model);
            sync_sessions_to_model(&store.borrow(), &sessions_model);
            if let Some(w) = weak.upgrade() {
                let hint = if added > 0 {
                    format!("{} {}", t("已导入", "imported"), added)
                } else {
                    t("没有新主机可导入", "no new hosts to import").to_string()
                };
                w.set_ssh_import_hint(hint.into());
            }
        });
    }

    // Edit -> open dialog prefilled.
    {
        let weak = window.as_weak();
        let store = store.clone();
        window.on_edit_session(move |id: SharedString| {
            let id = id.to_string();
            let store = store.borrow();
            let Some(session) = store.get(&id) else { return; };
            if let Some(w) = weak.upgrade() {
                w.set_dialog_id(session.id.clone().into());
                w.set_dialog_name(session.name.clone().into());
                w.set_dialog_group(session_group(&session.group).into());
                w.set_dialog_host(session.host.clone().into());
                w.set_dialog_port(session.port.to_string().into());
                w.set_dialog_user(session.user.clone().into());
                w.set_dialog_auth(session.auth.as_str().into());
                // Never echo the stored password back into the UI (issue #10) —
                // leave it blank; a blank field on save keeps the existing one.
                w.set_dialog_password("".into());
                w.set_dialog_key_path(session.private_key_path.clone().into());
                w.set_dialog_proxy(session.proxy.clone().into());
                w.set_dialog_kind(session.kind.as_str().into());
                w.set_dialog_serial_port(session.serial_port.clone().into());
                w.set_dialog_baud(session.baud_rate.to_string().into());
                w.set_dialog_data_bits(session.data_bits.to_string().into());
                w.set_dialog_stop_bits(session.stop_bits.to_string().into());
                w.set_dialog_parity(session.parity.clone().into());
                w.set_dialog_flow(session.flow_control.clone().into());
                w.set_dialog_editing(true);
                w.set_dialog_open(true);
            }
        });
    }

    // Remove session.
    {
        let weak = window.as_weak();
        let store = store.clone();
        let sessions_model = sessions_model.clone();
        window.on_remove_session(move |id: SharedString| {
            {
                let mut s = store.borrow_mut();
                s.remove(&id.to_string());
                if let Err(err) = s.save() {
                    tracing::warn!("failed to save config: {err:#}");
                }
            }
            sync_sessions_to_model(&store.borrow(), &sessions_model);
            if let Some(w) = weak.upgrade() {
                // Touch a property so the list re-renders reliably.
                let _ = w.get_sessions();
            }
        });
    }

    // Dialog submit -> persist + (optionally) connect.
    {
        let weak = window.as_weak();
        let store = store.clone();
        let sessions_model = sessions_model.clone();
        window.on_session_dialog_submit(move |draft: SessionDraft| {
            let id = draft.id.to_string();
            let auth = AuthMethod::from_str(&draft.auth.to_string());
            let existing = store.borrow().get(&id).cloned();
            let group = store
                .borrow()
                .connection_folder_or_default(&draft.group.to_string());
            // The edit dialog never echoes the real password (issue #10): a blank
            // field while editing means "keep the existing password" rather than
            // "clear it".  Only overwrite when the user actually typed something.
            let password = if auth == AuthMethod::Password {
                if draft.password.is_empty() {
                    existing
                        .as_ref()
                        .map(|s| s.password.clone())
                        .unwrap_or_default()
                } else {
                    Secret::new(draft.password.to_string())
                }
            } else {
                Secret::default()
            };
            let password_ref = if auth == AuthMethod::Password {
                existing
                    .as_ref()
                    .map(|s| s.password_ref.clone())
                    .unwrap_or_default()
            } else {
                String::new()
            };
            let kind = crate::config::SessionKind::from_str(&draft.kind.to_string());
            // Auto-name: serial → port label, otherwise user@host.
            let auto_name = match kind {
                crate::config::SessionKind::Serial => {
                    format!("{} @{}", draft.serial_port, draft.baud_rate)
                }
                _ => format!("{}@{}", draft.user, draft.host),
            };
            // Telnet defaults to port 23, SSH to 22; serial ignores port.
            let default_port = if kind == crate::config::SessionKind::Telnet {
                23
            } else {
                22
            };
            let new_session = Session {
                id,
                name: if draft.name.is_empty() {
                    auto_name
                } else {
                    draft.name.to_string()
                },
                group,
                host: draft.host.to_string(),
                port: if draft.port <= 0 {
                    default_port
                } else {
                    draft.port as u16
                },
                user: draft.user.to_string(),
                auth,
                password,
                password_ref,
                // Store the key path with forward slashes uniformly.
                private_key_path: draft.private_key_path.to_string().replace('\\', "/"),
                proxy: draft.proxy.to_string(),
                last_used: None,
                kind,
                serial_port: draft.serial_port.to_string(),
                baud_rate: if draft.baud_rate <= 0 {
                    115_200
                } else {
                    draft.baud_rate as u32
                },
                data_bits: draft.data_bits as u8,
                stop_bits: draft.stop_bits as u8,
                parity: draft.parity.to_string(),
                flow_control: draft.flow_control.to_string(),
            };
            {
                let mut s = store.borrow_mut();
                s.upsert(new_session);
                if let Err(err) = s.save() {
                    tracing::warn!("failed to save config: {err:#}");
                }
            }
            sync_sessions_to_model(&store.borrow(), &sessions_model);
            if let Some(w) = weak.upgrade() {
                w.set_dialog_open(false);
            }
        });
    }

    // Cancel dialog.
    {
        let weak = window.as_weak();
        window.on_session_dialog_cancel(move || {
            if let Some(w) = weak.upgrade() {
                w.set_dialog_open(false);
            }
        });
    }

    // Private-key file picker: pick the private key and store its path with
    // forward-slash separators (uniform across Windows/Linux; russh accepts them).
    {
        let weak = window.as_weak();
        window.on_session_dialog_pick_key(move || {
            let mut dialog = rfd::FileDialog::new().set_title(t("选择私钥文件", "Choose private key file"));
            // Start in ~/.ssh if it exists.
            if let Some(home) = directories::UserDirs::new().map(|u| u.home_dir().join(".ssh")) {
                if home.is_dir() {
                    dialog = dialog.set_directory(home);
                }
            }
            if let Some(file) = dialog.pick_file() {
                let path = file.to_string_lossy().replace('\\', "/");
                if let Some(w) = weak.upgrade() {
                    w.set_dialog_key_path(path.into());
                }
            }
        });
    }

    // Connect session -> open a new terminal tab.
    {
        let weak = window.as_weak();
        let store = store.clone();
        let tabs_model = tabs_model.clone();
        let terminals_model = terminals_model.clone();
        let handles = handles.clone();
        let bufs = bufs.clone();
        let runtime = runtime.clone();
        let last_term_size = last_term_size.clone();
        let sftp_handles = sftp_handles.clone();
        let sftp_manual_nav = sftp_manual_nav.clone();
        let tab_statuses = tab_statuses.clone();
        window.on_connect_session(move |id: SharedString| {
            let id = id.to_string();
            let session = match store.borrow().get(&id).cloned() {
                Some(s) => s,
                None => return,
            };
            let max_tabs = store.borrow().max_terminal_tabs() as usize;
            if tabs_model.row_count() >= max_tabs {
                if let Some(w) = weak.upgrade() {
                    let msg = format!(
                        "{} {}",
                        t("窗口数量已达到上限", "Window limit reached"),
                        max_tabs
                    );
                    w.set_ssh_import_hint(msg.clone().into());
                    w.set_conn_state(2);
                    w.set_connection_state(msg.into());
                }
                return;
            }
            let tab_id = format!("term-{}", uuid::Uuid::new_v4());
            let tab_title = session.name.clone();

            // Connection label shown in the sidebar / status line, per transport.
            let conn_label = match session.kind {
                SessionKind::Ssh => format!("{}@{}", session.user, session.host),
                SessionKind::Serial => {
                    format!("{} @{}", session.serial_port, session.baud_rate)
                }
                SessionKind::Telnet => format!("telnet {}:{}", session.host, session.port),
            };
            // Serial / Telnet have no SFTP side-channel.
            let has_sftp = session.kind == SessionKind::Ssh;
            let known_hosts_path = store.borrow().config_dir().join("known_hosts");

            // Seed the per-tab status so the sidebar shows "连接中 host" the
            // moment this tab becomes active (the `changed active-tab-id`
            // handler fires refresh-sidebar right after set_active_tab_id below).
            tab_statuses.lock().unwrap().insert(
                tab_id.clone(),
                TabStatus {
                    host: conn_label.clone(),
                    state: 0,
                    ..Default::default()
                },
            );

            // Register tab + terminal state (SFTP fields start empty/loading).
            tabs_model.push(TabInfo {
                id: tab_id.clone().into(),
                title: tab_title.into(),
                kind: "terminal".into(),
                connected: false,
            });
            terminals_model.push(TerminalState {
                id: tab_id.clone().into(),
                tab_id: tab_id.clone().into(),
                status: t("连接中...", "Connecting...").into(),
                spans: ModelRc::from(std::rc::Rc::new(VecModel::<TermSpan>::default())),
                cursor_row: 0,
                cursor_col: 0,
                rows_used: 0,
                is_alt_screen: false,
                mouse_tracking: false,
                find_matches: ModelRc::from(std::rc::Rc::new(VecModel::<TermMatch>::default())),
                selection: ModelRc::from(std::rc::Rc::new(VecModel::<TermMatch>::default())),
                sftp_path: "/".into(),
                sftp_entries: ModelRc::from(
                    std::rc::Rc::new(VecModel::<SftpEntry>::default()),
                ),
                sftp_status: if has_sftp {
                    t("SFTP 连接中...", "SFTP connecting...").into()
                } else {
                    t("此会话类型不支持 SFTP", "SFTP not available for this session").into()
                },
                sftp_loading: has_sftp,
                sftp_tree_nodes: ModelRc::from(
                    std::rc::Rc::new(VecModel::<SftpTreeNode>::default()),
                ),
            });
            // Create vt100 parser for this tab (default 24×80; resized on first
            // terminal-resize callback). 5000-line scrollback is stored for
            // future scroll-navigation support.
            let is_dark_now = weak.upgrade().map(|w| w.get_dark_mode()).unwrap_or(true);
            bufs.lock().unwrap().insert(
                tab_id.clone(),
                TermBuffer {
                    parser: vt100::Parser::new(24, 80, 5000),
                    find_query: String::new(),
                    is_dark: is_dark_now,
                    sel_anchor: None,
                    sel_focus: None,
                    history: Vec::new(),
                    prev: Vec::new(),
                    view_offset: 0,
                    displayed_text: Vec::new(),
                    csi_state: CsiState::Normal,
                },
            );
            // Start in cd-auto-follow mode (flag = false → follow cd).
            sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), false);
            if let Some(w) = weak.upgrade() {
                w.set_active_tab_id(tab_id.clone().into());
            }

            // Spawn SSH shell worker.
            //
            // Pass the current best-known terminal dimensions so the remote PTY
            // is opened at (approximately) the right size. The resize callback
            // will fire again shortly and send an accurate window_change if
            // needed.
            let (initial_cols, initial_rows) = *last_term_size.lock().unwrap();
            let (handle, rx) = match session.kind {
                SessionKind::Ssh => spawn_session(
                    runtime.handle(),
                    tab_id.clone(),
                    session.clone(),
                    known_hosts_path.clone(),
                    initial_cols,
                    initial_rows,
                ),
                SessionKind::Serial => crate::serial::spawn_serial_session(
                    runtime.handle(),
                    tab_id.clone(),
                    session.clone(),
                ),
                SessionKind::Telnet => crate::telnet::spawn_telnet_session(
                    runtime.handle(),
                    tab_id.clone(),
                    session.clone(),
                    initial_cols,
                    initial_rows,
                ),
            };
            handles.borrow_mut().insert(tab_id.clone(), handle);

            // Spawn separate SFTP connection for the same session.
            // The SFTP worker pushes SessionEvent::SftpEntries / SftpStatus
            // back via the same receiver channel (rx) — no second receiver
            // needed because spawn_sftp accepts an UnboundedSender clone.
            // Only SSH sessions get an SFTP side-channel; Serial / Telnet skip it.
            let sftp_evt_tx = if has_sftp {
                // spawn_session doesn't expose its event sender, so SFTP gets its
                // own channel; the dedicated pump below merges its events in.
                let (sftp_tx, sftp_rx) = tokio::sync::mpsc::unbounded_channel::<SessionEvent>();
                let sftp_handle = spawn_sftp(runtime.handle(), session, known_hosts_path, sftp_tx);
                sftp_handles.lock().unwrap().insert(tab_id.clone(), sftp_handle);
                Some(sftp_rx)
            } else {
                None
            };

            // --- Shell event pump (dedicated thread) ----------------------
            // Blocks on shell events; handles CwdChanged with 500 ms debounce.
            {
                let weak_inner = weak.clone();
                let bufs_thread = bufs.clone();
                let sftp_handles_pump = sftp_handles.clone();
                let sftp_manual_nav_pump = sftp_manual_nav.clone();
                let rt_pump = runtime.clone();
                let tab_id_pump = tab_id.clone();
                let statuses_pump = tab_statuses.clone();
                let transfer_seq_pump = transfer_seq.clone();
                std::thread::spawn(move || {
                    let mut shell_rx = rx;
                    let mut cwd_debounce: Option<tokio::task::JoinHandle<()>> = None;
                    loop {
                        match shell_rx.blocking_recv() {
                            None => break,
                            Some(shell_evt) => {
                                if let SessionEvent::CwdChanged(ref cwd) = shell_evt {
                                    let is_manual = sftp_manual_nav_pump
                                        .lock()
                                        .ok()
                                        .and_then(|m| m.get(&tab_id_pump).copied())
                                        .unwrap_or(false);
                                    if !is_manual {
                                        if let Some(prev) = cwd_debounce.take() {
                                            prev.abort();
                                        }
                                        let cwd = cwd.clone();
                                        let sftp_h = sftp_handles_pump.clone();
                                        let tid = tab_id_pump.clone();
                                        cwd_debounce = Some(rt_pump.spawn(async move {
                                            tokio::time::sleep(
                                                std::time::Duration::from_millis(500),
                                            )
                                            .await;
                                            if let Ok(handles) = sftp_h.lock() {
                                                if let Some(h) = handles.get(&tid) {
                                                    h.list_dir(cwd);
                                                }
                                            }
                                        }));
                                    }
                                }
                                let weak_evt = weak_inner.clone();
                                let tid = tab_id_pump.clone();
                                let bufs_evt = bufs_thread.clone();
                                let st_evt = statuses_pump.clone();
                                let seq_evt = transfer_seq_pump.clone();
                                let _ = slint::invoke_from_event_loop(move || {
                                    if let Some(win) = weak_evt.upgrade() {
                                        apply_session_event_to_window(
                                            &win, &tid, shell_evt, &bufs_evt,
                                            &st_evt, &seq_evt,
                                        );
                                    }
                                });
                            }
                        }
                    }
                });
            }

            // --- SFTP event pump (separate thread) -------------------------
            // Never blocks on shell; dispatches SFTP events the moment they
            // arrive so tree/file-list updates are immediate even when the
            // terminal is idle.  Only present for SSH sessions.
            if let Some(sftp_evt_tx) = sftp_evt_tx {
                let weak_sftp = weak.clone();
                let bufs_sftp = bufs.clone();
                let tab_id_sftp = tab_id.clone();
                let statuses_sftp = tab_statuses.clone();
                let transfer_seq_sftp = transfer_seq.clone();
                std::thread::spawn(move || {
                    let mut sftp_rx = sftp_evt_tx;
                    loop {
                        match sftp_rx.blocking_recv() {
                            None => break,
                            Some(sftp_evt) => {
                                let weak_s = weak_sftp.clone();
                                let tid = tab_id_sftp.clone();
                                let bufs_s = bufs_sftp.clone();
                                let st_s = statuses_sftp.clone();
                                let seq_s = transfer_seq_sftp.clone();
                                let _ = slint::invoke_from_event_loop(move || {
                                    if let Some(win) = weak_s.upgrade() {
                                        apply_session_event_to_window(
                                            &win, &tid, sftp_evt, &bufs_s,
                                            &st_s, &seq_s,
                                        );
                                    }
                                });
                            }
                        }
                    }
                });
            }
        });
    }

}

/// Push a value into a fixed-length ring buffer (newest at the end).
fn push_ring(buf: &mut Vec<f32>, val: f32) {
    if buf.len() != NET_HISTORY_LEN {
        *buf = vec![0.0; NET_HISTORY_LEN];
    }
    buf.remove(0);
    buf.push(val);
}

/// Auto-scale a raw bytes/sec history to 0..1 against its own window peak so the
/// sparkline always uses the full height (like FinalShell's relative graph).
fn normalized_model(buf: &[f32]) -> ModelRc<f32> {
    let max = buf.iter().cloned().fold(1.0_f32, f32::max);
    let scaled: Vec<f32> = buf.iter().map(|v| (v / max).clamp(0.0, 1.0)).collect();
    ModelRc::from(Rc::new(VecModel::from(scaled)))
}

/// Build the filesystem-usage model (path, "avail/total", used fraction).
fn disk_model(disks: &[(String, u64, u64)]) -> ModelRc<DiskInfo> {
    let rows: Vec<DiskInfo> = disks
        .iter()
        .map(|(mount, avail, total)| {
            let used = total.saturating_sub(*avail);
            let percent = if *total > 0 {
                used as f32 / *total as f32
            } else {
                0.0
            };
            DiskInfo {
                path: mount.clone().into(),
                detail: format!("{}/{}", format_size(*avail), format_size(*total)).into(),
                percent,
            }
        })
        .collect();
    ModelRc::from(Rc::new(VecModel::from(rows)))
}

fn process_model(processes: &[(u64, f32, String)]) -> ModelRc<ProcessInfo> {
    let rows: Vec<ProcessInfo> = processes
        .iter()
        .take(5)
        .map(|(mem_bytes, cpu_percent, name)| ProcessInfo {
            mem: format_bytes_short(*mem_bytes).into(),
            cpu: format!("{:.0}%", cpu_percent.clamp(0.0, 999.0)).into(),
            name: name.clone().into(),
        })
        .collect();
    ModelRc::from(Rc::new(VecModel::from(rows)))
}

fn format_cpu_cores(cores: u32) -> String {
    if cores == 0 {
        return String::new();
    }
    if crate::i18n::is_en() {
        let suffix = if cores == 1 { "core" } else { "cores" };
        format!("{cores} {suffix}")
    } else {
        format!("{cores}核")
    }
}

fn format_server_running(secs: u64) -> String {
    let (value, zh_unit, en_unit) = if secs < 3600 {
        ((secs / 60).max(1), "分钟", "minute")
    } else if secs < 86_400 {
        ((secs / 3600).max(1), "小时", "hour")
    } else {
        ((secs / 86_400).max(1), "天", "day")
    };

    if crate::i18n::is_en() {
        let suffix = if value == 1 { "" } else { "s" };
        format!("Server running for {value} {en_unit}{suffix}")
    } else {
        format!("服务器已运行{value}{zh_unit}")
    }
}

/// Find every (case-insensitive) occurrence of `query` across the currently
/// displayed rows and return highlight rectangles (char index == grid column).
fn compute_find_matches(rows: &[String], query: &str) -> Vec<TermMatch> {
    let mut out: Vec<TermMatch> = Vec::new();
    if query.is_empty() {
        return out;
    }
    let q: Vec<char> = query.chars().map(|c| c.to_ascii_lowercase()).collect();
    if q.is_empty() {
        return out;
    }
    for (r, line) in rows.iter().enumerate() {
        let lower: Vec<char> = line.chars().map(|c| c.to_ascii_lowercase()).collect();
        let mut i = 0usize;
        while i + q.len() <= lower.len() {
            if lower[i..i + q.len()] == q[..] {
                out.push(TermMatch {
                    row: r as i32,
                    col: i as i32,
                    len: q.len() as i32,
                });
                i += q.len();
            } else {
                i += 1;
            }
        }
    }
    out
}

/// Recompute spans + cursor + find/selection highlights for one tab from its
/// current vt100 screen (respecting scrollback) and push them to the model.
/// Used by scroll + selection callbacks (Output has its own equivalent inline).
fn rebuild_tab_display(win: &AppWindow, bufs: &TermBuffers, tab_id: &str) {
    let data = {
        let mut map = bufs.lock().unwrap();
        let Some(buf) = map.get_mut(tab_id) else { return };
        let cols = buf.parser.screen().size().1;
        let b = buf.render(); // also refreshes buf.displayed_text
        let matches = compute_find_matches(&buf.displayed_text, &buf.find_query);
        let sel = buf.selection_rects_visible(cols);
        let mouse = buf.mouse_tracking_enabled();
        (b, matches, sel, mouse)
    };
    let (b, matches, sel, mouse) = data;
    let spans = ModelRc::from(Rc::new(VecModel::from(b.spans)));
    let fm = ModelRc::from(Rc::new(VecModel::from(matches)));
    let sm = ModelRc::from(Rc::new(VecModel::from(sel)));
    let (cr, cc, ru, alt) = (b.cursor_row, b.cursor_col, b.rows_used, b.is_alt);
    set_terminal_row(win, tab_id, move |row| {
        row.spans = spans.clone();
        row.cursor_row = cr;
        row.cursor_col = cc;
        row.rows_used = ru;
        row.is_alt_screen = alt;
        row.mouse_tracking = mouse;
        row.find_matches = fm.clone();
        row.selection = sm.clone();
    });
}

/// Resolve which interface drives the top sparkline: the user's selection if it
/// still exists, otherwise the busiest (the list is sorted busiest-first).
/// Returns (name, rx_bps, tx_bps).
fn selected_iface(st: &TabStatus) -> (String, u64, u64) {
    if !st.selected_iface.is_empty() {
        if let Some(e) = st.net.iter().find(|e| e.0 == st.selected_iface) {
            return e.clone();
        }
    }
    st.net.first().cloned().unwrap_or_default()
}

/// Recompute the whole sidebar for whichever tab is active. Only live session
/// tabs show resource data; welcome/connecting/disconnected states stay empty.
/// Must run on the Slint event loop thread.
fn refresh_sidebar(
    win: &AppWindow,
    statuses: &TabStatuses,
) {
    let pct = |used: u64, total: u64| -> f32 {
        if total > 0 {
            used as f32 / total as f32
        } else {
            0.0
        }
    };
    let empty_history =
        || ModelRc::from(Rc::new(VecModel::from(vec![0.0_f32; NET_HISTORY_LEN])));
    let clear_remote_data = |win: &AppWindow| {
        win.set_net_top_up("".into());
        win.set_net_top_down("".into());
        win.set_net_top_history(empty_history());
        win.set_net_show_selector(false);
        win.set_net_selected("".into());
        win.set_net_ifaces(ModelRc::from(Rc::new(VecModel::<SharedString>::default())));
        win.set_disks(disk_model(&[]));
        win.set_processes(process_model(&[]));
    };
    let clear_stats = |win: &AppWindow| {
        win.set_cpu_percent(0.0);
        win.set_mem_percent(0.0);
        win.set_swap_percent(0.0);
        win.set_cpu_detail("".into());
        win.set_mem_detail("".into());
        win.set_swap_detail("".into());
    };

    let active = win.get_active_tab_id().to_string();
    let status = if active == "welcome" {
        None
    } else {
        statuses.lock().unwrap().get(&active).cloned()
    };

    match status {
        // A live session tab → remote resources + remote NIC on top.
        Some(st) if st.state == 1 => {
            win.set_conn_state(1);
            let running_secs = if st.uptime_secs > 0 {
                st.uptime_secs
            } else {
                st.connected_since
                    .map(|at| at.elapsed().as_secs())
                    .unwrap_or(0)
            };
            win.set_connection_state(format_server_running(running_secs).into());
            win.set_resource_title(t("服务器资源", "Server resources").into());
            win.set_cpu_percent(st.cpu);
            win.set_mem_percent(pct(st.mem_used_kib, st.mem_total_kib));
            win.set_swap_percent(pct(st.swap_used_kib, st.swap_total_kib));
            win.set_cpu_detail(format_cpu_cores(st.cpu_cores).into());
            win.set_mem_detail(
                format_mem_mib(st.mem_used_kib / 1024, st.mem_total_kib / 1024).into(),
            );
            win.set_swap_detail(
                format_mem_mib(st.swap_used_kib / 1024, st.swap_total_kib / 1024).into(),
            );
            let (name, rx, tx) = selected_iface(&st);
            win.set_net_top_up(format_bytes_per_sec(tx).into());
            win.set_net_top_down(format_bytes_per_sec(rx).into());
            win.set_net_top_history(normalized_model(&st.net_hist));
            win.set_net_show_selector(!st.net.is_empty());
            win.set_net_selected(name.into());
            let ifaces: Vec<SharedString> =
                st.net.iter().map(|e| e.0.clone().into()).collect();
            win.set_net_ifaces(ModelRc::from(Rc::new(VecModel::from(ifaces))));
            win.set_disks(disk_model(&st.disks));
            win.set_processes(process_model(&st.processes));
        }
        // Disconnected / timed-out session.
        Some(st) if st.state == 2 => {
            win.set_conn_state(2);
            win.set_connection_state(format!("{} {}", st.host, t("已断开", "disconnected")).into());
            win.set_resource_title(t("服务器资源", "Server resources").into());
            clear_stats(win);
            clear_remote_data(win);
        }
        // Still connecting.
        Some(st) => {
            win.set_conn_state(0);
            win.set_connection_state(format!("{} {}", t("连接中", "Connecting"), st.host).into());
            win.set_resource_title(t("服务器资源", "Server resources").into());
            clear_stats(win);
            clear_remote_data(win);
        }
        // Welcome tab (or unknown) → no local sampling or display.
        None => {
            win.set_conn_state(0);
            win.set_connection_state(t("未连接", "Not connected").into());
            win.set_resource_title("".into());
            clear_stats(win);
            clear_remote_data(win);
        }
    }
}

/// Apply a session event to the live UI models. Must be called on the Slint
/// event loop thread.
fn apply_session_event_to_window(
    win: &AppWindow,
    tab_id: &str,
    event: SessionEvent,
    bufs: &TermBuffers,
    statuses: &TabStatuses,
    transfer_seq: &TransferSeq,
) {
    let tabs_rc = win.get_tabs();
    let terminals_rc = win.get_terminals();
    // `ModelRc::as_any` lets us downcast to the concrete `VecModel<T>`.
    let tabs = tabs_rc
        .as_any()
        .downcast_ref::<VecModel<TabInfo>>()
        .expect("tabs model must be a VecModel");
    let terminals = terminals_rc
        .as_any()
        .downcast_ref::<VecModel<TerminalState>>()
        .expect("terminals model must be a VecModel");
    let update_terminal = |mutator: &dyn Fn(&mut TerminalState)| {
        for i in 0..terminals.row_count() {
            if let Some(mut row) = terminals.row_data(i) {
                if row.id.as_str() == tab_id {
                    mutator(&mut row);
                    terminals.set_row_data(i, row);
                    break;
                }
            }
        }
    };
    let update_tab = |mutator: &dyn Fn(&mut TabInfo)| {
        for i in 0..tabs.row_count() {
            if let Some(mut row) = tabs.row_data(i) {
                if row.id.as_str() == tab_id {
                    mutator(&mut row);
                    tabs.set_row_data(i, row);
                    break;
                }
            }
        }
    };

    match event {
        SessionEvent::Status(status) => {
            update_terminal(&|t| t.status = status.clone().into());
        }
        SessionEvent::Output(chunk) => {
            // Feed raw bytes into the vt100 parser. vt100 correctly handles
            // cursor movement, \r + line-redraw (readline), \x1b[K (erase to
            // EOL), alternate-screen switching, and all VT100/xterm sequences.
            // We then split the rendered screen at cursor_position() so Slint
            // can insert the blinking "█" at the exact cursor cell.
            let built = {
                let mut map = bufs.lock().unwrap();
                if let Some(buf) = map.get_mut(tab_id) {
                    // Capture scrolled-off lines into history, then render the
                    // current view (live or scrolled-back).
                    buf.ingest(chunk.as_bytes());
                    let cols = buf.parser.screen().size().1;
                    let b = buf.render(); // refreshes buf.displayed_text
                    let matches = compute_find_matches(&buf.displayed_text, &buf.find_query);
                    let sel = buf.selection_rects_visible(cols);
                    let mouse = buf.mouse_tracking_enabled();
                    Some((b, matches, sel, mouse))
                } else {
                    None
                }
            };
            if let Some((b, matches, sel, mouse)) = built {
                let spans_model: ModelRc<TermSpan> =
                    ModelRc::from(std::rc::Rc::new(VecModel::from(b.spans)));
                let matches_model: ModelRc<TermMatch> =
                    ModelRc::from(std::rc::Rc::new(VecModel::from(matches)));
                let sel_model: ModelRc<TermMatch> =
                    ModelRc::from(std::rc::Rc::new(VecModel::from(sel)));
                let (cur_row, cur_col, rows_used, is_alt) =
                    (b.cursor_row, b.cursor_col, b.rows_used, b.is_alt);
                update_terminal(&|t| {
                    t.spans = spans_model.clone();
                    t.cursor_row = cur_row;
                    t.cursor_col = cur_col;
                    t.rows_used = rows_used;
                    t.is_alt_screen = is_alt;
                    t.mouse_tracking = mouse;
                    t.find_matches = matches_model.clone();
                    t.selection = sel_model.clone();
                });
            }
        }
        SessionEvent::Connected => {
            update_tab(&|t| t.connected = true);
            update_terminal(&|t| t.status = crate::i18n::t("已连接", "Connected").into());
            if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) {
                st.state = 1;
                if st.connected_since.is_none() {
                    st.connected_since = Some(Instant::now());
                }
            }
            if win.get_active_tab_id().as_str() == tab_id {
                refresh_sidebar(win, statuses);
            }
        }
        SessionEvent::Closed(reason) => {
            update_tab(&|t| t.connected = false);
            update_terminal(&|t| t.status = format!("{} — {reason}", crate::i18n::t("已断开", "Disconnected")).into());
            if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) {
                st.state = 2;
                st.connected_since = None;
            }
            if win.get_active_tab_id().as_str() == tab_id {
                refresh_sidebar(win, statuses);
            }
        }
        SessionEvent::ResourceStats {
            cpu_percent,
            cpu_cores,
            mem_used_kib,
            mem_total_kib,
            swap_used_kib,
            swap_total_kib,
            uptime_secs,
            net,
            disks,
            processes,
        } => {
            if let Some(st) = statuses.lock().unwrap().get_mut(tab_id) {
                st.cpu = cpu_percent;
                st.cpu_cores = cpu_cores;
                st.mem_used_kib = mem_used_kib;
                st.mem_total_kib = mem_total_kib;
                st.swap_used_kib = swap_used_kib;
                st.swap_total_kib = swap_total_kib;
                st.uptime_secs = uptime_secs;
                st.net = net;
                st.disks = disks;
                st.processes = processes;
                // A sample means the channel is alive → treat as connected.
                if st.state != 1 {
                    st.state = 1;
                    st.connected_since = Some(Instant::now());
                } else if st.connected_since.is_none() {
                    st.connected_since = Some(Instant::now());
                }
                // Append the selected interface's total rate to its sparkline.
                let (_, rx, tx) = selected_iface(st);
                push_ring(&mut st.net_hist, (rx + tx) as f32);
            }
            if win.get_active_tab_id().as_str() == tab_id {
                refresh_sidebar(win, statuses);
            }
        }

        // --- SFTP events ---------------------------------------------------
        SessionEvent::CwdChanged(path) => {
            // Just update the displayed path; the pump thread already sent
            // SftpCommand::ListDir so a SftpEntries event is inbound.
            update_terminal(&|t| {
                t.sftp_path = path.clone().into();
                t.sftp_loading = true;
            });
        }
        SessionEvent::SftpEntries { path, entries } => {
            if !win.get_sftp_name_width_manual() {
                let suggested =
                    suggested_sftp_name_column_width(&entries, win.get_sftp_file_font_size());
                win.set_sftp_name_column_width(suggested);
            }
            let slint_entries: Vec<SftpEntry> = entries
                .iter()
                .map(|e| SftpEntry {
                    name: e.name.clone().into(),
                    full_path: e.full_path.clone().into(),
                    is_dir: e.is_dir,
                    size: if e.is_dir {
                        "".into()
                    } else {
                        format_size(e.size).into()
                    },
                    modified: format_mtime(e.modified).into(),
                })
                .collect();
            let model = ModelRc::from(
                std::rc::Rc::new(VecModel::from(slint_entries)),
            );
            update_terminal(&|t| {
                t.sftp_path = path.clone().into();
                t.sftp_entries = model.clone();
                t.sftp_loading = false;
            });
        }
        SessionEvent::SftpStatus(msg) => {
            update_terminal(&|t| t.sftp_status = msg.clone().into());
        }
        SessionEvent::SftpTreeUpdate(nodes) => {
            let slint_nodes: Vec<SftpTreeNode> = nodes
                .iter()
                .map(|n| SftpTreeNode {
                    path: n.path.clone().into(),
                    name: n.name.clone().into(),
                    depth: n.depth as i32,
                    expanded: n.expanded,
                    has_children: n.has_children,
                })
                .collect();
            let model = ModelRc::from(std::rc::Rc::new(VecModel::from(slint_nodes)));
            update_terminal(&|t| t.sftp_tree_nodes = model.clone());
        }
        SessionEvent::SftpTransfer {
            id,
            name,
            is_upload,
            transferred,
            total,
            state,
            msg,
        } => {
            win.set_download_open(true);
            win.set_settings_open(false);
            win.set_config_open(false);
            let detail = match state {
                2 => {
                    if msg.is_empty() {
                        t("失败", "Failed").to_string()
                    } else {
                        msg
                    }
                }
                1 => t("已完成", "Done").to_string(),
                _ => {
                    if total > 0 {
                        format!("{}/{}", format_size(transferred), format_size(total))
                    } else {
                        format_size(transferred)
                    }
                }
            };
            let percent = if state == 1 {
                1.0
            } else if total > 0 {
                (transferred as f32 / total as f32).clamp(0.0, 1.0)
            } else {
                0.0
            };
            if let Some(model) = win
                .get_transfers()
                .as_any()
                .downcast_ref::<VecModel<TransferInfo>>()
            {
                let mut found = None;
                let mut display_name = name;
                for i in 0..model.row_count() {
                    if let Some(row) = model.row_data(i) {
                        if row.id.as_str() == id.as_str() {
                            display_name = row.name.to_string();
                            found = Some(i);
                            break;
                        }
                    }
                }
                if found.is_none() {
                    let seq = transfer_seq.fetch_add(1, Ordering::Relaxed) + 1;
                    display_name = format!("#{seq} {display_name}");
                }
                let rec = TransferInfo {
                    id: id.clone().into(),
                    name: display_name.into(),
                    detail: detail.into(),
                    percent,
                    state: state as i32,
                    is_upload,
                };
                match found {
                    Some(i) => model.set_row_data(i, rec),
                    None => model.insert(0, rec), // newest at top
                }
            }
        }
    }
}

// ---------------------------------------------------------------------------
// Tab callbacks
// ---------------------------------------------------------------------------

const TAB_VISIBLE_COUNT: usize = 5;

fn keep_tab_visible(window: &AppWindow, tabs_model: &VecModel<TabInfo>, target_id: &str) {
    let row_count = tabs_model.row_count();
    if row_count <= TAB_VISIBLE_COUNT {
        window.set_tab_visible_start(0);
        return;
    }

    let Some(idx) = (0..row_count).find(|i| {
        tabs_model
            .row_data(*i)
            .map(|tab| tab.id.as_str() == target_id)
            .unwrap_or(false)
    }) else {
        return;
    };

    let max_start = row_count.saturating_sub(TAB_VISIBLE_COUNT);
    let current_start = window.get_tab_visible_start().max(0) as usize;
    let mut start = current_start.min(max_start);
    if idx < start {
        start = idx;
    } else if idx >= start + TAB_VISIBLE_COUNT {
        start = idx + 1 - TAB_VISIBLE_COUNT;
    }
    window.set_tab_visible_start(start.min(max_start) as i32);
}

fn select_relative_terminal_tab(
    window: &AppWindow,
    tabs_model: &VecModel<TabInfo>,
    direction: isize,
) {
    let mut terminal_ids = Vec::new();
    for i in 0..tabs_model.row_count() {
        if let Some(tab) = tabs_model.row_data(i) {
            if tab.kind.as_str() != "welcome" {
                terminal_ids.push(tab.id.to_string());
            }
        }
    }
    if terminal_ids.is_empty() {
        return;
    }

    let active = window.get_active_tab_id().to_string();
    let current = terminal_ids.iter().position(|id| id == &active);
    let target = match (current, direction < 0) {
        (Some(0), true) => terminal_ids.len() - 1,
        (Some(i), true) => i - 1,
        (Some(i), false) => (i + 1) % terminal_ids.len(),
        (None, true) => terminal_ids.len() - 1,
        (None, false) => 0,
    };

    let target_id = &terminal_ids[target];
    window.set_active_tab_id(target_id.clone().into());
    keep_tab_visible(window, tabs_model, target_id);
}

fn wire_tab_callbacks(
    window: &AppWindow,
    tabs_model: Rc<VecModel<TabInfo>>,
    terminals_model: Rc<VecModel<TerminalState>>,
    handles: Rc<RefCell<HashMap<String, SessionHandle>>>,
    bufs: TermBuffers,
    sftp_handles: SftpHandles,
    sftp_manual_nav: SftpManualNav,
) {
    // Selecting a tab is already applied inside the Slint callback; we just
    // need to keep the C++/Rust state in sync if needed.
    {
        let weak = window.as_weak();
        let tabs_model = tabs_model.clone();
        window.on_tab_selected(move |id: SharedString| {
            // AppWindow.active-tab-id is updated inline in the .slint.
            if let Some(w) = weak.upgrade() {
                keep_tab_visible(&w, &tabs_model, id.as_str());
            }
        });
    }

    {
        let weak = window.as_weak();
        let tabs_model = tabs_model.clone();
        let terminals_model = terminals_model.clone();
        let handles = handles.clone();
        let bufs = bufs.clone();
        let sftp_handles = sftp_handles.clone();
        let sftp_manual_nav = sftp_manual_nav.clone();
        window.on_tab_closed(move |id: SharedString| {
            let id = id.to_string();
            if id == "welcome" {
                return;
            }
            if let Some(handle) = handles.borrow_mut().remove(&id) {
                handle.close();
            }
            if let Some(sftp) = sftp_handles.lock().unwrap().remove(&id) {
                sftp.close();
            }
            sftp_manual_nav.lock().unwrap().remove(&id);
            bufs.lock().unwrap().remove(&id);

            // Remove from tabs + terminals models.
            let mut idx = None;
            for i in 0..tabs_model.row_count() {
                if tabs_model
                    .row_data(i)
                    .map(|r| r.id.as_str() == id)
                    .unwrap_or(false)
                {
                    idx = Some(i);
                    break;
                }
            }
            if let Some(i) = idx {
                tabs_model.remove(i);
            }
            let mut i = terminals_model.row_count();
            while i > 0 {
                i -= 1;
                if terminals_model
                    .row_data(i)
                    .map(|r| r.id.as_str() == id)
                    .unwrap_or(false)
                {
                    terminals_model.remove(i);
                }
            }

            // If we closed the active tab, fall back to the welcome page.
            if let Some(w) = weak.upgrade() {
                let active = w.get_active_tab_id().to_string();
                if active == id {
                    w.set_active_tab_id("welcome".into());
                    w.set_tab_visible_start(0);
                } else {
                    keep_tab_visible(&w, &tabs_model, &active);
                }
            }
        });
    }

    {
        let weak = window.as_weak();
        window.on_new_tab_clicked(move || {
            if let Some(w) = weak.upgrade() {
                w.set_active_tab_id("welcome".into());
            }
        });
    }

    {
        let weak = window.as_weak();
        let tabs_model = tabs_model.clone();
        window.on_previous_tab_clicked(move || {
            if let Some(w) = weak.upgrade() {
                select_relative_terminal_tab(&w, &tabs_model, -1);
            }
        });
    }

    {
        let weak = window.as_weak();
        let tabs_model = tabs_model.clone();
        window.on_next_tab_clicked(move || {
            if let Some(w) = weak.upgrade() {
                select_relative_terminal_tab(&w, &tabs_model, 1);
            }
        });
    }
}

// ---------------------------------------------------------------------------
// SFTP callbacks
// ---------------------------------------------------------------------------

fn wire_sftp_callbacks(
    window: &AppWindow,
    sftp_handles: SftpHandles,
    sftp_manual_nav: SftpManualNav,
) {
    // Navigate to a remote path (or ".." to go up one level).
    {
        let sftp_handles = sftp_handles.clone();
        let sftp_manual_nav = sftp_manual_nav.clone();
        let weak = window.as_weak();
        window.on_sftp_navigate(move |tab_id: SharedString, path: SharedString| {
            let tab_id = tab_id.to_string();
            let resolved = if path.as_str() == ".." {
                let current = weak.upgrade().and_then(|w| {
                    let terminals_rc = w.get_terminals();
                    let terminals = terminals_rc
                        .as_any()
                        .downcast_ref::<VecModel<TerminalState>>()?;
                    for i in 0..terminals.row_count() {
                        if let Some(row) = terminals.row_data(i) {
                            if row.id.as_str() == tab_id {
                                return Some(row.sftp_path.to_string());
                            }
                        }
                    }
                    None
                });
                parent_path(&current.unwrap_or_else(|| "/".to_string()))
            } else {
                path.to_string()
            };
            // Any manual navigation stops cd auto-follow.
            sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), true);
            if let Ok(handles) = sftp_handles.lock() {
                if let Some(h) = handles.get(&tab_id) {
                    h.list_dir(resolved);
                }
            }
        });
    }

    // Download a remote file.  If a download folder is preset in settings, save
    // straight there; otherwise fall back to a native folder picker.
    {
        let sftp_handles = sftp_handles.clone();
        let weak = window.as_weak();
        window.on_sftp_download(move |tab_id: SharedString, remote_path: SharedString| {
            let tab_id = tab_id.to_string();
            let remote_path = remote_path.to_string();
            let preset = weak
                .upgrade()
                .map(|w| w.get_download_dir().to_string())
                .unwrap_or_default();
            if !preset.is_empty() {
                if let Ok(handles) = sftp_handles.lock() {
                    if let Some(h) = handles.get(&tab_id) {
                        h.download(remote_path, preset);
                    }
                }
                return;
            }
            let sftp_handles = sftp_handles.clone();
            std::thread::spawn(move || {
                if let Some(dir) = rfd::FileDialog::new().pick_folder() {
                    let local_dir = dir.to_string_lossy().to_string();
                    if let Ok(handles) = sftp_handles.lock() {
                        if let Some(h) = handles.get(&tab_id) {
                            h.download(remote_path, local_dir);
                        }
                    }
                }
            });
        });
    }

    // Upload a local file into the current remote directory.
    {
        let sftp_handles = sftp_handles.clone();
        window.on_sftp_upload_clicked(
            move |tab_id: SharedString, remote_dir: SharedString| {
                let tab_id = tab_id.to_string();
                let remote_dir = remote_dir.to_string();
                let sftp_handles = sftp_handles.clone();
                std::thread::spawn(move || {
                    if let Some(file) = rfd::FileDialog::new().pick_file() {
                        let local = file.to_string_lossy().to_string();
                        if let Ok(handles) = sftp_handles.lock() {
                            if let Some(h) = handles.get(&tab_id) {
                                h.upload(local, remote_dir);
                            }
                        }
                    }
                });
            },
        );
    }

    // Refresh the current directory listing.
    {
        let sftp_handles = sftp_handles.clone();
        window.on_sftp_refresh(move |tab_id: SharedString, path: SharedString| {
            let tab_id = tab_id.to_string();
            let path = path.to_string();
            if let Ok(handles) = sftp_handles.lock() {
                if let Some(h) = handles.get(&tab_id) {
                    h.list_dir(path);
                }
            }
        });
    }

    // Toggle tree node expand/collapse and navigate to that directory.
    {
        let sftp_handles = sftp_handles.clone();
        let sftp_manual_nav = sftp_manual_nav.clone();
        window.on_sftp_tree_expand(move |tab_id: SharedString, path: SharedString| {
            let tab_id = tab_id.to_string();
            let path = path.to_string();
            // Manual tree navigation stops cd auto-follow.
            sftp_manual_nav.lock().unwrap().insert(tab_id.clone(), true);
            if let Ok(handles) = sftp_handles.lock() {
                if let Some(h) = handles.get(&tab_id) {
                    h.toggle_tree_node(path.clone());
                    h.list_dir(path);
                }
            }
        });
    }

    // Context menu → 删除 a remote file. The irreversible-delete confirmation
    // (#28) is handled by the in-app ConfirmDialog in the UI layer, so by the
    // time this fires the user has already confirmed.
    {
        let sftp_handles = sftp_handles.clone();
        window.on_sftp_delete(move |tab_id: SharedString, path: SharedString| {
            if let Ok(handles) = sftp_handles.lock() {
                if let Some(h) = handles.get(tab_id.as_str()) {
                    h.delete(path.to_string());
                }
            }
        });
    }

    // Context menu → 重命名 / 编辑 (open + auto-reupload).
    {
        let sftp_handles = sftp_handles.clone();
        window.on_sftp_rename(
            move |tab_id: SharedString, path: SharedString, new_name: SharedString| {
                let new_name = new_name.trim().to_string();
                if new_name.is_empty() {
                    return;
                }
                if let Ok(handles) = sftp_handles.lock() {
                    if let Some(h) = handles.get(tab_id.as_str()) {
                        h.rename(path.to_string(), new_name);
                    }
                }
            },
        );
    }
    {
        let sftp_handles = sftp_handles.clone();
        window.on_sftp_edit(move |tab_id: SharedString, path: SharedString| {
            if let Ok(handles) = sftp_handles.lock() {
                if let Some(h) = handles.get(tab_id.as_str()) {
                    h.open_temp(path.to_string(), true);
                }
            }
        });
    }
}

// ---------------------------------------------------------------------------
// Raw keystroke forwarding and PTY resize
// ---------------------------------------------------------------------------

fn wire_key_input(
    window: &AppWindow,
    handles: Rc<RefCell<HashMap<String, SessionHandle>>>,
    bufs: TermBuffers,
    last_term_size: Arc<Mutex<(u32, u32)>>,
) {
    // Forward each keystroke as raw bytes to the SSH PTY. The server's bash /
    // readline handles echo, history (↑↓), Tab completion, Ctrl+C, etc.
    {
        let handles = handles.clone();
        let bufs = bufs.clone();
        // Shared timestamp: the last time the Shift key alone was pressed
        // (key="", shift=true).  Used by the time-based Backspace filter below.
        let last_shift_time: Arc<Mutex<Option<std::time::Instant>>> =
            Arc::new(Mutex::new(None));
        window.on_send_key(move |tab_id: SharedString, key: SharedString, ctrl: bool, alt: bool, shift: bool| {
            // Check whether the remote PTY switched to application cursor mode
            // (DECCKM, set by nano/vim via \x1b[?1h). In that mode the terminal
            // must send \x1bOA/B/C/D instead of \x1b[A/B/C/D.
            let app_cursor = {
                let mut map = bufs.lock().unwrap();
                match map.get_mut(tab_id.as_str()) {
                    Some(b) => {
                        // Typing snaps the view back to the live bottom so the
                        // user always sees what they're entering.
                        b.view_offset = 0;
                        b.parser.screen().application_cursor()
                    }
                    None => false,
                }
            };
            // Never log the raw key string — it can be a password character
            // (#15). redact_key keeps control codes but masks printable text.
            tracing::debug!(
                "send_key tab={} key={} ctrl={} alt={} shift={} app_cursor={}",
                tab_id, redact_key(key.as_str()), ctrl, alt, shift, app_cursor
            );

            // ── Shift / Backspace 诊断日志 (info 级, 无需 RUST_LOG=debug) ─────
            // 每个 Shift 相关事件都打印 key 的 Unicode 码位，方便对比
            // 左Shift / 右Shift 是否产生不同的 key 字符串。
            if shift || key.as_str() == "\u{0008}" {
                // INFO level (no RUST_LOG needed) — must not leak the key text.
                // redact_key reveals only control code points (the IME markers
                // this diagnostic cares about), masking any printable char that
                // could be part of a Shift-typed password symbol (#15).
                let codepoints = redact_key(key.as_str());
                let elapsed_ms = last_shift_time
                    .lock()
                    .unwrap()
                    .map(|t| format!("{}ms ago", t.elapsed().as_millis()))
                    .unwrap_or_else(|| "never".to_string());
                tracing::info!(
                    "[KEY_DIAG] key={} shift={} ctrl={} alt={} | last_shift={}",
                    codepoints, shift, ctrl, alt, elapsed_ms
                );
            }

            // ── Track lone-Shift presses for the time-based Backspace filter ──
            // Slint sends key="" (empty string) when a bare modifier key (Shift,
            // Ctrl, Alt) is pressed.  We record the timestamp whenever Shift
            // alone fires so the filter below can catch IME-injected Backspace
            // events even if they arrive with shift=false.
            if key.as_str().is_empty() && shift && !ctrl && !alt {
                *last_shift_time.lock().unwrap() = Some(std::time::Instant::now());
                tracing::info!("[KEY_DIAG] lone-Shift recorded → timestamp saved");
            }

            // ── 拦截百度拼音注入的 Shift 标记字符（核心修复）────────────────────
            // 诊断日志证实，百度拼音通过 WH_KEYBOARD_LL 钩子，在 Shift 键按下时
            // 向消息队列注入一个 C0 控制字符，而非空字符串：
            //
            //   左 Shift → U+0015 (Ctrl+U / NAK), shift=true, ctrl=false
            //   右 Shift → U+0010 (Ctrl+P / DLE), shift=true, ctrl=false
            //              紧接着注入: U+0008 (Backspace), shift=false
            //
            // 这些字符绝对不应送入 PTY：
            //   0x15 (Ctrl+U) 在 bash/vim 中会清空当前输入行 → "左Shift替换字符"
            //   0x10 (Ctrl+P) 在 vim 中翻历史/触发补全     → "右Shift乱跳"
            //   0x08 (Backspace) 紧随其后                   → "右Shift删除字符"
            //
            // 合法独立 C0 键（Backspace=0x08, Tab=0x09, LF=0x0A, CR=0x0D,
            // ESC=0x1B）不受此过滤影响，由下方代码单独处理。
            //
            // 检测到 IME Shift 标记后，记录时间戳，让 Layer 2 在 1500ms 内
            // 拦截随后可能到来的 Backspace（右Shift场景，日志显示间隔约 914ms）。
            if !ctrl && !alt {
                if let Some(c) = key.as_str().chars().next() {
                    let cp = c as u32;
                    let is_standalone = matches!(cp, 0x08 | 0x09 | 0x0A | 0x0D | 0x1B);
                    if key.as_str().chars().count() == 1
                        && (0x01..=0x1f).contains(&cp)
                        && !is_standalone
                    {
                        *last_shift_time.lock().unwrap() = Some(std::time::Instant::now());
                        tracing::info!(
                            "[KEY_DIAG] DROPPED IME C0 marker U+{:04X} (shift={}) → timestamp saved",
                            cp, shift
                        );
                        return;
                    }
                }
            }

            // ── Windows: filter synthetic Ctrl+char injections ──────────────
            // Some keyboards / IME drivers (e.g. Aula F99 + Baidu Pinyin)
            // inject a synthetic WM_CHAR 0x11 (Ctrl+Q) when Left Ctrl is
            // briefly tapped, WITHOUT sending a WM_KEYDOWN VK_Q beforehand.
            //
            // FinalShell avoids this because it builds Ctrl+letter from
            // WM_KEYDOWN (virtual-key codes).  Slint uses WM_CHAR, so it
            // sees the injected byte and forwards it straight to us.
            //
            // Fix: for C0 control chars (Ctrl+A…Ctrl+Z, i.e. 0x01–0x1A),
            // use GetKeyState — which returns the key state *as of the last
            // processed message*, not the live hardware state — to verify
            // the corresponding letter VK was actually queued as a keydown
            // before this WM_CHAR arrived.  If Q was never keyed down,
            // GetKeyState(VK_Q) = 0 → the event is synthetic → drop it.
            #[cfg(windows)]
            if ctrl {
                if let Some(ch) = key.as_str().chars().next() {
                    let cp = ch as u32;
                    // Always let Enter / Tab pass through regardless of Ctrl
                    // state.  These C0 codes (0x09 Tab, 0x0a LF, 0x0d CR) are
                    // "double-duty" keys: pressing Enter while Ctrl is still
                    // physically held (e.g. just after Ctrl+O in nano) generates
                    // Ctrl+M (0x0d) with ctrl=true — but GetKeyState(VK_M) is 0
                    // because the user never pressed M.  Without this exemption
                    // the filter would silently drop the Enter, making it
                    // impossible to confirm nano's "File Name to Write:" prompt.
                    let always_pass = matches!(cp, 0x09 | 0x0a | 0x0d);
                    if !always_pass
                        && key.as_str().chars().count() == 1
                        && (0x01..=0x1a).contains(&cp)
                        && !c0_letter_key_down(cp)
                    {
                        tracing::debug!(
                            "send_key: dropped synthetic Ctrl+{} \
                             (VK_{:02X} not down per GetKeyState)",
                            (0x40u8 + cp as u8) as char,
                            cp + 0x40
                        );
                        return;
                    }
                }
            }

            // ── Filter synthetic Backspace injected by Chinese IME ────────────
            // Baidu Pinyin (and similar Chinese IMEs) hooks the keyboard at the
            // driver level via WH_KEYBOARD_LL, below Win32's ImmDisableIME.
            // When the user presses Shift to switch from Chinese to English mode
            // while a pinyin syllable is in-flight, the IME:
            //   1. Cancels the composition (discards the syllable).
            //   2. Posts WM_KEYDOWN VK_BACK + WM_CHAR 0x08 to erase whatever
            //      character it had already forwarded to the app.
            //
            // Three-layer defence:
            //
            //   Layer 1 – shift=true guard.
            //     The synthetic Backspace arrives during Shift keydown, so
            //     GetKeyState(VK_SHIFT) is still "down" → Slint reports shift=true.
            //     Drop any Backspace (0x08) arriving while Shift is flagged.
            //
            //   Layer 2 – time-based guard.
            //     Baidu Pinyin posts WM_CHAR 0x08 asynchronously, so by the time
            //     the message is dequeued Shift may already read as "up"
            //     → shift=false defeats Layer 1.
            //     Mitigation: we recorded the timestamp when the Shift key alone
            //     was pressed (key="", shift=true) a few lines above.  Drop any
            //     Backspace arriving within 200 ms of that moment.
            //
            //   Layer 3 – GetKeyState guard (belt-and-suspenders).
            //     If VK_BACK is not actually "down" (i.e. no real WM_KEYDOWN
            //     VK_BACK was ever queued), the Backspace must be synthetic.
            if key.as_str() == "\u{0008}" && !ctrl && !alt {
                // Layer 1
                if shift {
                    tracing::info!("[KEY_DIAG] Backspace DROPPED by layer-1 (shift=true)");
                    return;
                }
                // Layer 2 — 时间窗口 1500ms
                // 日志显示百度拼音注入 U+0010(右Shift标记) 到 Backspace 之间
                // 间隔约 914ms，因此窗口设为 1500ms 以覆盖该场景。
                let (shift_just_pressed, elapsed_ms) = {
                    let guard = last_shift_time.lock().unwrap();
                    match *guard {
                        Some(t) => {
                            let ms = t.elapsed().as_millis();
                            (ms < 1500, ms)
                        }
                        None => (false, 0),
                    }
                };
                if shift_just_pressed {
                    tracing::info!(
                        "[KEY_DIAG] Backspace DROPPED by layer-2 ({}ms after IME Shift marker)",
                        elapsed_ms
                    );
                    return;
                }
                // Layer 3
                #[cfg(windows)]
                if !is_vk_back_down() {
                    tracing::info!("[KEY_DIAG] Backspace DROPPED by layer-3 (VK_BACK not down)");
                    return;
                }
                tracing::info!("[KEY_DIAG] Backspace PASSED all filters → sent to PTY");
            }

            let bytes = key_to_pty_bytes(key.as_str(), ctrl, alt, app_cursor);
            // Log only the length — never the keystroke bytes, which can be
            // password characters (#15).
            tracing::debug!(
                "send_key len={} handle_exists={}",
                bytes.len(),
                handles.borrow().contains_key(tab_id.as_str()),
            );
            if !bytes.is_empty() {
                if let Some(handle) = handles.borrow().get(tab_id.as_str()) {
                    handle.send_raw(bytes);
                }
            }
        });
    }

    // Terminal mouse reporting for tmux/vim/mc/etc.  vt100 tracks whether the
    // remote enabled xterm mouse mode; we only emit mouse bytes while that mode
    // is active so normal shell clicks keep working as selection/context-menu.
    {
        let handles = handles.clone();
        let bufs_mouse = bufs.clone();
        window.on_terminal_mouse_event(
            move |tab_id: SharedString, kind: SharedString, button: i32, row: i32, col: i32| {
                let bytes = {
                    let map = bufs_mouse.lock().unwrap();
                    let Some(buf) = map.get(tab_id.as_str()) else { return };
                    let screen = buf.parser.screen();
                    let (rows, cols) = screen.size();
                    terminal_mouse_event_bytes(
                        screen.mouse_protocol_mode(),
                        screen.mouse_protocol_encoding(),
                        kind.as_str(),
                        button,
                        row,
                        col,
                        rows,
                        cols,
                    )
                };
                let Some(bytes) = bytes else { return };
                if let Some(handle) = handles.borrow().get(tab_id.as_str()) {
                    handle.send_raw(bytes);
                }
            },
        );
    }

    // Propagate PTY resize to the SSH worker and vt100 parser. Pixel
    // dimensions come from Slint; we approximate col/row counts using
    // Consolas 13px metrics.
    //
    // terminal_view.slint now passes the FocusScope height (not the full
    // TerminalView height), so the SFTP panel is already excluded.
    // Layout breakdown for the FocusScope:
    //   16 px  – bottom strip (TouchArea for focus-regain)
    //    8 px  – y-offset of the output Text element inside the Flickable
    // = 24 px  total vertical chrome within FocusScope
    //
    // Consolas 13 px renders at ≈ 8 px wide × 16 px tall per cell.
    {
        let handles = handles.clone();
        let bufs_resize = bufs.clone(); // keep bufs alive for the copy handler below
        // The Slint side now measures the real Consolas cell size (via a hidden
        // probe Text) and passes whole column/row counts directly, so there is
        // no pixel→cell guesswork here.  This keeps full-screen programs like
        // nano from over-counting rows and clipping their bottom shortcut bar.
        window.on_terminal_resize(move |tab_id: SharedString, cols_f: f32, rows_f: f32| {
            let cols = (cols_f as u32).max(10);
            let rows = (rows_f as u32).max(5);
            tracing::debug!(
                "terminal_resize tab={} cols={} rows={}",
                tab_id, cols, rows
            );
            // Keep the shared size up-to-date so future connections start
            // with the correct PTY dimensions.
            *last_term_size.lock().unwrap() = (cols, rows);
            if let Some(handle) = handles.borrow().get(tab_id.as_str()) {
                handle.resize(cols, rows);
            }
            if let Some(buf) = bufs_resize.lock().unwrap().get_mut(tab_id.as_str()) {
                let (old_rows, old_cols) = buf.parser.screen().size();
                let new_rows = rows as u16;
                // Shrinking the grid (e.g. dragging the SFTP panel up) makes
                // vt100's set_size truncate rows from the BOTTOM — silently
                // dropping the most recent output + prompt (#18).  To keep the
                // bottom (recent) rows we scroll the screen up first, but only
                // by as much as is needed to keep the CURSOR on-screen: the rows
                // *below* the cursor are unused blank space and can be truncated
                // for free.  Scrolling by the full delta instead would push real
                // content off the top into scrollback whenever the screen wasn't
                // full — e.g. a fresh shell with a few prompt lines — leaving a
                // blank grid with the cursor stranded at the top, and rapid
                // up/down dragging would repeat that until the prompt was gone.
                // Skipped on the alternate screen (vim/btop own their buffer).
                if new_rows < old_rows && !buf.parser.screen().alternate_screen() {
                    let (cursor_row, _) = buf.parser.screen().cursor_position();
                    // Rows that must scroll off the top to keep the cursor in view.
                    let scroll = (cursor_row + 1).saturating_sub(new_rows);
                    if scroll > 0 {
                        let saved: Vec<Line> = {
                            let s = buf.parser.screen();
                            (0..scroll).map(|r| build_row(s, r, old_cols)).collect()
                        };
                        for line in saved {
                            buf.history.push(line);
                        }
                        if buf.history.len() > MAX_HISTORY {
                            let drop = buf.history.len() - MAX_HISTORY;
                            buf.history.drain(0..drop);
                        }
                        buf.parser.process(format!("\x1b[{scroll}S").as_bytes());
                    }
                }
                buf.parser.set_size(new_rows, cols as u16);
                // The pre/post-resize screens differ in size+content; drop the
                // scroll-detection snapshot so the next output isn't mis-read as
                // a scroll (which would double-capture lines).
                buf.prev.clear();
            }
        });
    }

    // Ctrl+Shift+C: copy current terminal screen to clipboard.
    {
        let bufs = bufs.clone();
        window.on_copy_terminal_text(move |tab_id: SharedString| {
            let text = {
                let map = bufs.lock().unwrap();
                match map.get(tab_id.as_str()) {
                    Some(buf) => {
                        // Copy the drag-selection when there is one, else the
                        // whole displayed screen.
                        let sel = buf.extract_selection_text();
                        if sel.is_empty() {
                            buf.displayed_text.join("\n")
                        } else {
                            sel
                        }
                    }
                    None => String::new(),
                }
            };
            // Run the clipboard write on a dedicated OS thread.  arboard's
            // Windows backend opens the clipboard and pumps Win32 messages;
            // doing that on the Slint/winit event-loop thread re-enters the
            // message loop and dead-locks the whole UI.
            std::thread::spawn(move || {
                match arboard::Clipboard::new().and_then(|mut cb| cb.set_text(text)) {
                    Ok(()) => tracing::debug!("copy_terminal: clipboard updated"),
                    Err(e) => tracing::warn!("copy_terminal: clipboard error: {}", e),
                }
            });
        });
    }

    // Middle-click / Ctrl+Shift+V: paste clipboard text into PTY.
    {
        let handles = handles.clone();
        window.on_paste_from_clipboard(move |tab_id: SharedString| {
            // Clone the (Send) command sender for this tab so the clipboard read
            // can run off the UI thread.  Reading arboard on the event-loop
            // thread is what froze the app on middle-click / paste — see the
            // copy handler above for the deadlock explanation.
            let sender = handles
                .borrow()
                .get(tab_id.as_str())
                .map(|h| h.commands.clone());
            let Some(sender) = sender else { return };
            std::thread::spawn(move || {
                match arboard::Clipboard::new().and_then(|mut cb| cb.get_text()) {
                    Ok(text) => {
                        let _ = sender.send(SessionCommand::RawInput(text.into_bytes()));
                    }
                    Err(e) => tracing::warn!("paste_from_clipboard: clipboard error: {}", e),
                }
            });
        });
    }

    // Context menu → 清空缓存: reset the local vt100 buffer (drops scrollback),
    // wipe the displayed screen, then nudge the remote to redraw a fresh prompt.
    {
        let bufs_clear = bufs.clone();
        let handles_clear = handles.clone();
        let weak = window.as_weak();
        window.on_clear_terminal(move |tab_id: SharedString| {
            let tid = tab_id.to_string();
            if let Some(buf) = bufs_clear.lock().unwrap().get_mut(&tid) {
                let (rows, cols) = buf.parser.screen().size();
                buf.parser = vt100::Parser::new(rows, cols, 5000);
                buf.find_query.clear();
                buf.history = Vec::new(); // recycle the session scrollback
                buf.prev = Vec::new();
                buf.view_offset = 0;
                buf.sel_anchor = None;
                buf.sel_focus = None;
                buf.displayed_text = Vec::new();
            }
            if let Some(win) = weak.upgrade() {
                set_terminal_row(&win, &tid, |row| {
                    row.spans =
                        ModelRc::from(Rc::new(VecModel::<TermSpan>::default()));
                    row.find_matches =
                        ModelRc::from(Rc::new(VecModel::<TermMatch>::default()));
                    row.selection =
                        ModelRc::from(Rc::new(VecModel::<TermMatch>::default()));
                    row.cursor_row = 0;
                    row.cursor_col = 0;
                    row.rows_used = 0;
                    row.mouse_tracking = false;
                });
            }
            if let Some(h) = handles_clear.borrow().get(&tid) {
                h.send_raw(vec![0x0c]); // Ctrl+L → shell clears + redraws prompt
            }
        });
    }

    // Context menu → 查找: store the query and recompute highlight rectangles.
    {
        let bufs_find = bufs.clone();
        let weak = window.as_weak();
        window.on_find_query_changed(move |tab_id: SharedString, query: SharedString| {
            let tid = tab_id.to_string();
            let q = query.to_string();
            let matches = {
                let mut map = bufs_find.lock().unwrap();
                if let Some(buf) = map.get_mut(&tid) {
                    buf.find_query = q.clone();
                    compute_find_matches(&buf.displayed_text, &q)
                } else {
                    Vec::new()
                }
            };
            if let Some(win) = weak.upgrade() {
                let model = ModelRc::from(Rc::new(VecModel::from(matches)));
                set_terminal_row(&win, &tid, |row| {
                    row.find_matches = model.clone();
                });
            }
        });
    }

    // Mouse-wheel → scroll the scrollback history.
    {
        let bufs_scroll = bufs.clone();
        let weak = window.as_weak();
        window.on_terminal_scroll(move |tab_id: SharedString, delta: i32| {
            let tid = tab_id.to_string();
            {
                let mut map = bufs_scroll.lock().unwrap();
                let Some(buf) = map.get_mut(&tid) else { return };
                // Scroll within our own session scrollback (history lines above
                // the live screen).  Offset 0 = live bottom.
                let max_off = buf.history.len() as i64;
                let cur = buf.view_offset as i64;
                buf.view_offset = (cur + delta as i64).clamp(0, max_off) as usize;
            }
            if let Some(win) = weak.upgrade() {
                rebuild_tab_display(&win, &bufs_scroll, &tid);
            }
        });
    }

    // Drag-selection lifecycle.
    {
        let bufs_sel = bufs.clone();
        let weak = window.as_weak();
        window.on_term_select_start(move |tab_id: SharedString, row: i32, col: i32| {
            let tid = tab_id.to_string();
            {
                let mut map = bufs_sel.lock().unwrap();
                let Some(buf) = map.get_mut(&tid) else { return };
                let (rows, cols) = buf.parser.screen().size();
                let r = row.clamp(0, rows.saturating_sub(1) as i32) as u16;
                let c = col.clamp(0, cols.saturating_sub(1) as i32) as u16;
                // Anchor + focus in absolute scrollback coordinates.
                let abs = buf.vis_to_abs(r);
                buf.sel_anchor = Some((abs, c));
                buf.sel_focus = Some((abs, c));
            }
            if let Some(win) = weak.upgrade() {
                rebuild_tab_display(&win, &bufs_sel, &tid);
            }
        });
    }
    {
        let bufs_sel = bufs.clone();
        let weak = window.as_weak();
        window.on_term_select_update(move |tab_id: SharedString, row: i32, col: i32| {
            let tid = tab_id.to_string();
            {
                let mut map = bufs_sel.lock().unwrap();
                let Some(buf) = map.get_mut(&tid) else { return };
                let (rows, cols) = buf.parser.screen().size();
                let r = row.clamp(0, rows.saturating_sub(1) as i32) as u16;
                let c = col.clamp(0, cols.saturating_sub(1) as i32) as u16;
                if buf.sel_anchor.is_some() {
                    let abs = buf.vis_to_abs(r);
                    buf.sel_focus = Some((abs, c));
                }
            }
            if let Some(win) = weak.upgrade() {
                rebuild_tab_display(&win, &bufs_sel, &tid);
            }
        });
    }
    {
        let bufs_sel = bufs.clone();
        let weak = window.as_weak();
        window.on_term_select_end(move |tab_id: SharedString| {
            let tid = tab_id.to_string();
            // Extract the selected text; a zero-area selection (a plain click)
            // is cleared instead of copied.
            let text = {
                let mut map = bufs_sel.lock().unwrap();
                let Some(buf) = map.get_mut(&tid) else { return };
                let extracted = buf.extract_selection_text();
                if extracted.is_empty() {
                    // Zero-area selection (a plain click) → clear it.
                    buf.sel_anchor = None;
                    buf.sel_focus = None;
                    None
                } else {
                    Some(extracted)
                }
            };
            match text {
                Some(t) if !t.is_empty() => {
                    // Auto-copy on release (select-to-copy, PuTTY style).
                    std::thread::spawn(move || {
                        let _ = arboard::Clipboard::new()
                            .and_then(|mut cb| cb.set_text(t));
                    });
                }
                _ => {}
            }
            if let Some(win) = weak.upgrade() {
                rebuild_tab_display(&win, &bufs_sel, &tid);
            }
        });
    }
    // Auto-scroll while drag-selecting past the visible top/bottom edge.  The
    // anchor is in absolute coordinates so it stays pinned no matter how far the
    // view moves; we only advance the scrollback view and re-point the focus at
    // the absolute row now sitting on the edge the mouse is parked against.
    {
        let bufs_sel = bufs.clone();
        let weak = window.as_weak();
        window.on_term_select_autoscroll(move |tab_id: SharedString, dir: i32| {
            let tid = tab_id.to_string();
            {
                let mut map = bufs_sel.lock().unwrap();
                let Some(buf) = map.get_mut(&tid) else { return };
                if buf.sel_anchor.is_none() {
                    return;
                }
                let rows = buf.parser.screen().size().0;
                let last = rows.saturating_sub(1);
                let max_off = buf.history.len();
                let step = 2usize;
                // Keep the focus column the user last dragged to.
                let focus_col = buf.sel_focus.map(|f| f.1).unwrap_or(0);
                let edge_vis = if dir < 0 {
                    // Mouse above the top → reveal older lines.
                    let new_off = (buf.view_offset + step).min(max_off);
                    if new_off == buf.view_offset {
                        return; // already at the oldest line
                    }
                    buf.view_offset = new_off;
                    0u16
                } else if dir > 0 {
                    // Mouse below the bottom → move toward the live tail.
                    let new_off = buf.view_offset.saturating_sub(step);
                    if new_off == buf.view_offset {
                        return; // already at the live bottom
                    }
                    buf.view_offset = new_off;
                    last
                } else {
                    return;
                };
                let abs = buf.vis_to_abs(edge_vis);
                buf.sel_focus = Some((abs, focus_col));
            }
            if let Some(win) = weak.upgrade() {
                rebuild_tab_display(&win, &bufs_sel, &tid);
            }
        });
    }
}

fn wire_command_callbacks(
    window: &AppWindow,
    store: Rc<RefCell<ConfigStore>>,
    command_categories_model: Rc<VecModel<CommandCategory>>,
    command_items_model: Rc<VecModel<CommandItem>>,
    handles: Rc<RefCell<HashMap<String, SessionHandle>>>,
) {
    {
        let store = store.clone();
        let weak = window.as_weak();
        window.on_command_category_select(move |id: SharedString| {
            let id = id.to_string();
            let name = store
                .borrow()
                .command_categories()
                .iter()
                .find(|c| c.id == id)
                .map(|c| c.name.clone())
                .unwrap_or_default();
            if let Some(w) = weak.upgrade() {
                w.set_command_selected_category(id.into());
                w.set_command_category_draft(name.into());
                clear_command_editor(&w);
            }
        });
    }

    {
        let store = store.clone();
        let categories = command_categories_model.clone();
        let weak = window.as_weak();
        window.on_command_category_add(move |name: SharedString| {
            let mut s = store.borrow_mut();
            let id = s.add_command_category(name.to_string());
            if let Err(err) = s.save() {
                tracing::warn!("failed to save command category: {err:#}");
            }
            sync_command_categories_to_model(&*s, &categories);
            let cat_name = s
                .command_categories()
                .iter()
                .find(|c| c.id == id)
                .map(|c| c.name.clone())
                .unwrap_or_default();
            drop(s);
            if let Some(w) = weak.upgrade() {
                w.set_command_selected_category(id.into());
                w.set_command_category_draft(cat_name.into());
                clear_command_editor(&w);
            }
        });
    }

    {
        let store = store.clone();
        let categories = command_categories_model.clone();
        let weak = window.as_weak();
        window.on_command_category_rename(move |id: SharedString, name: SharedString| {
            let mut s = store.borrow_mut();
            s.rename_command_category(id.as_str(), name.to_string());
            if let Err(err) = s.save() {
                tracing::warn!("failed to rename command category: {err:#}");
            }
            sync_command_categories_to_model(&*s, &categories);
            if let Some(w) = weak.upgrade() {
                w.set_command_category_draft(name.to_string().into());
            }
        });
    }

    {
        let store = store.clone();
        let categories = command_categories_model.clone();
        let commands = command_items_model.clone();
        let weak = window.as_weak();
        window.on_command_category_delete(move |id: SharedString| {
            let mut s = store.borrow_mut();
            s.remove_command_category(id.as_str());
            if let Err(err) = s.save() {
                tracing::warn!("failed to delete command category: {err:#}");
            }
            sync_command_categories_to_model(&*s, &categories);
            sync_commands_to_model(&*s, &commands);
            drop(s);
            if let Some(w) = weak.upgrade() {
                w.set_command_selected_category(DEFAULT_COMMAND_CATEGORY_ID.into());
                w.set_command_category_draft("默认分类".into());
                clear_command_editor(&w);
            }
        });
    }

    {
        let weak = window.as_weak();
        window.on_command_new(move |category: SharedString| {
            if let Some(w) = weak.upgrade() {
                let category = if category.is_empty() {
                    DEFAULT_COMMAND_CATEGORY_ID.to_string()
                } else {
                    category.to_string()
                };
                w.set_command_selected_category(category.into());
                w.set_command_selected_id("".into());
                w.set_command_edit_id("".into());
                w.set_command_edit_name("".into());
                w.set_command_edit_command("".into());
                w.set_command_edit_append_cr(true);
                clear_command_params(&w);
            }
        });
    }

    {
        let store = store.clone();
        let weak = window.as_weak();
        window.on_command_select(move |id: SharedString| {
            if let Some(w) = weak.upgrade() {
                select_command_in_window(&w, &store.borrow(), id.as_str());
            }
        });
    }

    {
        let store = store.clone();
        let commands = command_items_model.clone();
        let weak = window.as_weak();
        window.on_command_save(
            move |id: SharedString,
                  category: SharedString,
                  name: SharedString,
                  command: SharedString,
                  append_cr: bool| {
                let mut s = store.borrow_mut();
                let category = if category.is_empty() {
                    DEFAULT_COMMAND_CATEGORY_ID
                } else {
                    category.as_str()
                };
                let saved_id = s.upsert_command(
                    id.as_str(),
                    category,
                    name.to_string(),
                    command.to_string(),
                    append_cr,
                );
                if let Err(err) = s.save() {
                    tracing::warn!("failed to save managed command: {err:#}");
                }
                sync_commands_to_model(&*s, &commands);
                if let (Some(w), Some(saved_id)) = (weak.upgrade(), saved_id) {
                    select_command_in_window(&w, &s, &saved_id);
                }
            },
        );
    }

    {
        let store = store.clone();
        let commands = command_items_model.clone();
        let weak = window.as_weak();
        window.on_command_delete(move |id: SharedString| {
            let mut s = store.borrow_mut();
            s.remove_command(id.as_str());
            if let Err(err) = s.save() {
                tracing::warn!("failed to delete managed command: {err:#}");
            }
            sync_commands_to_model(&*s, &commands);
            drop(s);
            if let Some(w) = weak.upgrade() {
                clear_command_editor(&w);
            }
        });
    }

    {
        let store = store.clone();
        let commands = command_items_model.clone();
        let weak = window.as_weak();
        window.on_command_move(move |id: SharedString, category: SharedString| {
            let mut s = store.borrow_mut();
            s.move_command(id.as_str(), category.as_str());
            if let Err(err) = s.save() {
                tracing::warn!("failed to move managed command: {err:#}");
            }
            sync_commands_to_model(&*s, &commands);
            let id = id.to_string();
            if let Some(w) = weak.upgrade() {
                select_command_in_window(&w, &s, &id);
            }
        });
    }

    {
        let store = store.clone();
        let handles = handles.clone();
        window.on_command_run(
            move |tab_id: SharedString,
                  command_id: SharedString,
                  p1: SharedString,
                  p2: SharedString,
                  p3: SharedString,
                  p4: SharedString,
                  p5: SharedString| {
                let rendered = {
                    let s = store.borrow();
                    let Some(cmd) = s.commands().iter().find(|c| c.id == command_id.as_str())
                    else {
                        return;
                    };
                    render_command_with_params(
                        &cmd.command,
                        &[
                            p1.to_string(),
                            p2.to_string(),
                            p3.to_string(),
                            p4.to_string(),
                            p5.to_string(),
                        ],
                    )
                    .map(|command| (command, cmd.append_cr))
                };
                let Some((command, append_cr)) = rendered else { return };
                if send_command_to_handle(&handles, tab_id.as_str(), &command, append_cr) {
                    let mut s = store.borrow_mut();
                    s.remember_command(command);
                    if let Err(err) = s.save() {
                        tracing::warn!("failed to remember managed command: {err:#}");
                    }
                }
            },
        );
    }
}

fn clear_command_editor(win: &AppWindow) {
    win.set_command_selected_id("".into());
    win.set_command_edit_id("".into());
    win.set_command_edit_name("".into());
    win.set_command_edit_command("".into());
    win.set_command_edit_append_cr(true);
    clear_command_params(win);
}

fn clear_command_params(win: &AppWindow) {
    win.set_command_param1_name("".into());
    win.set_command_param1_value("".into());
    win.set_command_param2_name("".into());
    win.set_command_param2_value("".into());
    win.set_command_param3_name("".into());
    win.set_command_param3_value("".into());
    win.set_command_param4_name("".into());
    win.set_command_param4_value("".into());
    win.set_command_param5_name("".into());
    win.set_command_param5_value("".into());
}

fn select_command_in_window(win: &AppWindow, store: &ConfigStore, id: &str) {
    let Some(cmd) = store.commands().iter().find(|c| c.id == id) else {
        clear_command_editor(win);
        return;
    };
    win.set_command_selected_id(cmd.id.clone().into());
    win.set_command_selected_category(cmd.category_id.clone().into());
    win.set_command_edit_id(cmd.id.clone().into());
    win.set_command_edit_name(cmd.name.clone().into());
    win.set_command_edit_command(cmd.command.clone().into());
    win.set_command_edit_append_cr(cmd.append_cr);
    if let Some(cat) = store
        .command_categories()
        .iter()
        .find(|c| c.id == cmd.category_id)
    {
        win.set_command_category_draft(cat.name.clone().into());
    }
    set_command_param_names(win, &extract_command_params(&cmd.command));
}

fn set_command_param_names(win: &AppWindow, params: &[String]) {
    clear_command_params(win);
    if let Some(p) = params.first() {
        win.set_command_param1_name(p.clone().into());
    }
    if let Some(p) = params.get(1) {
        win.set_command_param2_name(p.clone().into());
    }
    if let Some(p) = params.get(2) {
        win.set_command_param3_name(p.clone().into());
    }
    if let Some(p) = params.get(3) {
        win.set_command_param4_name(p.clone().into());
    }
    if let Some(p) = params.get(4) {
        win.set_command_param5_name(p.clone().into());
    }
}

fn extract_command_params(command: &str) -> Vec<String> {
    let mut params: Vec<(usize, String)> = Vec::new();
    let mut offset = 0usize;
    while let Some(rel) = command[offset..].find("[p#") {
        let start = offset + rel;
        let Some(end_rel) = command[start..].find(']') else {
            break;
        };
        let end = start + end_rel;
        let body = &command[start + 3..end];
        let digits_len = body
            .chars()
            .take_while(|ch| ch.is_ascii_digit())
            .map(char::len_utf8)
            .sum::<usize>();
        if digits_len > 0 {
            if let Ok(idx) = body[..digits_len].parse::<usize>() {
                if (1..=5).contains(&idx) && !params.iter().any(|(i, _)| *i == idx) {
                    let name = body[digits_len..].trim();
                    params.push((
                        idx,
                        if name.is_empty() {
                            format!("参数{idx}")
                        } else {
                            name.to_string()
                        },
                    ));
                }
            }
        }
        offset = end + 1;
    }
    params.sort_by_key(|(idx, _)| *idx);
    params.into_iter().map(|(_, name)| name).collect()
}

fn render_command_with_params(command: &str, values: &[String]) -> Option<String> {
    let mut out = String::new();
    let mut offset = 0usize;
    while let Some(rel) = command[offset..].find("[p#") {
        let start = offset + rel;
        out.push_str(&command[offset..start]);
        let Some(end_rel) = command[start..].find(']') else {
            out.push_str(&command[start..]);
            return Some(out);
        };
        let end = start + end_rel;
        let body = &command[start + 3..end];
        let digits_len = body
            .chars()
            .take_while(|ch| ch.is_ascii_digit())
            .map(char::len_utf8)
            .sum::<usize>();
        if digits_len == 0 {
            out.push_str(&command[start..=end]);
        } else if let Ok(idx) = body[..digits_len].parse::<usize>() {
            if !(1..=5).contains(&idx) {
                out.push_str(&command[start..=end]);
                offset = end + 1;
                continue;
            }
            let value = values.get(idx.saturating_sub(1))?.trim();
            if value.is_empty() {
                return None;
            }
            out.push_str(&value);
        } else {
            out.push_str(&command[start..=end]);
        }
        offset = end + 1;
    }
    out.push_str(&command[offset..]);
    Some(out)
}

fn send_command_to_handle(
    handles: &Rc<RefCell<HashMap<String, SessionHandle>>>,
    tab_id: &str,
    command: &str,
    append_cr: bool,
) -> bool {
    let command = command.trim();
    if command.is_empty() {
        return false;
    }
    let mut bytes = command.as_bytes().to_vec();
    if append_cr {
        bytes.push(0x0d);
    }
    if let Some(handle) = handles.borrow().get(tab_id) {
        handle.send_raw(bytes);
        true
    } else {
        false
    }
}

fn terminal_mouse_event_bytes(
    mode: vt100::MouseProtocolMode,
    encoding: vt100::MouseProtocolEncoding,
    kind: &str,
    button: i32,
    row: i32,
    col: i32,
    rows: u16,
    cols: u16,
) -> Option<Vec<u8>> {
    if matches!(mode, vt100::MouseProtocolMode::None) {
        return None;
    }
    if kind == "up" && matches!(mode, vt100::MouseProtocolMode::Press) {
        return None;
    }

    let code = match kind {
        "down" if (0..=2).contains(&button) => button,
        "up" if (0..=2).contains(&button) => {
            if matches!(encoding, vt100::MouseProtocolEncoding::Sgr) {
                button
            } else {
                3
            }
        }
        "drag"
            if (0..=2).contains(&button)
                && matches!(
                    mode,
                    vt100::MouseProtocolMode::ButtonMotion
                        | vt100::MouseProtocolMode::AnyMotion
                ) =>
        {
            button + 32
        }
        "move" if matches!(mode, vt100::MouseProtocolMode::AnyMotion) => 35,
        "wheel" if button == 64 || button == 65 => button,
        _ => return None,
    };

    let max_row = rows.saturating_sub(1) as i32;
    let max_col = cols.saturating_sub(1) as i32;
    let y = row.clamp(0, max_row) + 1;
    let x = col.clamp(0, max_col) + 1;

    if matches!(encoding, vt100::MouseProtocolEncoding::Sgr) {
        let suffix = if kind == "up" { 'm' } else { 'M' };
        return Some(format!("\x1b[<{};{};{}{}", code, x, y, suffix).into_bytes());
    }

    let mut out = b"\x1b[M".to_vec();
    push_x10_mouse_value(&mut out, code as u32, encoding)?;
    push_x10_mouse_value(&mut out, x as u32, encoding)?;
    push_x10_mouse_value(&mut out, y as u32, encoding)?;
    Some(out)
}

fn push_x10_mouse_value(
    out: &mut Vec<u8>,
    value: u32,
    encoding: vt100::MouseProtocolEncoding,
) -> Option<()> {
    let codepoint = value + 32;
    if matches!(encoding, vt100::MouseProtocolEncoding::Utf8) {
        let ch = char::from_u32(codepoint)?;
        let mut buf = [0u8; 4];
        out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes());
    } else {
        if codepoint > u8::MAX as u32 {
            return None;
        }
        out.push(codepoint as u8);
    }
    Some(())
}

#[cfg(test)]
mod mouse_event_tests {
    use super::terminal_mouse_event_bytes;

    #[test]
    fn sgr_click_uses_down_and_release_events() {
        assert_eq!(
            terminal_mouse_event_bytes(
                vt100::MouseProtocolMode::PressRelease,
                vt100::MouseProtocolEncoding::Sgr,
                "down",
                0,
                4,
                9,
                24,
                80,
            )
            .unwrap(),
            b"\x1b[<0;10;5M".to_vec()
        );
        assert_eq!(
            terminal_mouse_event_bytes(
                vt100::MouseProtocolMode::PressRelease,
                vt100::MouseProtocolEncoding::Sgr,
                "up",
                0,
                4,
                9,
                24,
                80,
            )
            .unwrap(),
            b"\x1b[<0;10;5m".to_vec()
        );
    }

    #[test]
    fn sgr_drag_requires_motion_mode() {
        assert!(terminal_mouse_event_bytes(
            vt100::MouseProtocolMode::PressRelease,
            vt100::MouseProtocolEncoding::Sgr,
            "drag",
            0,
            4,
            9,
            24,
            80,
        )
        .is_none());
        assert_eq!(
            terminal_mouse_event_bytes(
                vt100::MouseProtocolMode::ButtonMotion,
                vt100::MouseProtocolEncoding::Sgr,
                "drag",
                0,
                4,
                9,
                24,
                80,
            )
            .unwrap(),
            b"\x1b[<32;10;5M".to_vec()
        );
    }
}

/// Mutate the `TerminalState` whose id matches `tab_id` in the live model.
/// Must run on the Slint event loop thread.
fn set_terminal_row(win: &AppWindow, tab_id: &str, mutator: impl Fn(&mut TerminalState)) {
    let terminals = win.get_terminals();
    let Some(model) = terminals.as_any().downcast_ref::<VecModel<TerminalState>>() else {
        return;
    };
    for i in 0..model.row_count() {
        if let Some(mut row) = model.row_data(i) {
            if row.id.as_str() == tab_id {
                mutator(&mut row);
                model.set_row_data(i, row);
                break;
            }
        }
    }
}

/// Convert a Slint `KeyEvent.text` + modifier flags into the byte sequence
/// that the remote PTY expects.
///
/// Slint uses Unicode Private Use Area (`\u{F700}`…) for special keys.
/// Regular printable characters and C0 control characters are passed as-is.
///
/// Render a key string for diagnostic logs WITHOUT leaking its content (#15).
///
/// Any printable character could be a password character, so we never emit it.
/// Only C0/C1 control code points (Backspace, Esc, the IME-injected 0x10/0x15
/// markers, …) are revealed — those are exactly what the Shift/Backspace IME
/// diagnostics need and are never password material. Printable characters are
/// collapsed to a count, so the logs stay useful without exposing keystrokes.
fn redact_key(key: &str) -> String {
    if key.is_empty() {
        return "(empty)".to_string();
    }
    let mut parts: Vec<String> = Vec::new();
    let mut printable = 0usize;
    for c in key.chars() {
        let cp = c as u32;
        if cp < 0x20 || (0x7f..=0x9f).contains(&cp) {
            parts.push(format!("U+{cp:04X}"));
        } else {
            printable += 1;
        }
    }
    if printable > 0 {
        parts.push(format!("<{printable} printable redacted>"));
    }
    parts.join(",")
}

/// `app_cursor` mirrors the remote terminal's DECCKM mode (`\x1b[?1h/l`):
/// when true the four arrow keys must use SS3 sequences (`\x1bOA`…) instead
/// of the default CSI sequences (`\x1b[A`…).  Full-screen apps like nano and
/// vim set this mode on startup.
fn key_to_pty_bytes(key: &str, ctrl: bool, alt: bool, app_cursor: bool) -> Vec<u8> {
    // --- Special keys (Slint PUA code points) ------------------------------
    // Arrow keys: respect DECCKM application-cursor mode.
    let special: Option<&[u8]> = match key {
        "\u{F700}" => Some(if app_cursor { b"\x1bOA" } else { b"\x1b[A" }), // Up
        "\u{F701}" => Some(if app_cursor { b"\x1bOB" } else { b"\x1b[B" }), // Down
        "\u{F702}" => Some(if app_cursor { b"\x1bOD" } else { b"\x1b[D" }), // Left
        "\u{F703}" => Some(if app_cursor { b"\x1bOC" } else { b"\x1b[C" }), // Right
        "\u{F729}" => Some(b"\x1b[H"),   // Home
        "\u{F72B}" => Some(b"\x1b[F"),   // End
        "\u{F72C}" => Some(b"\x1b[5~"),  // PageUp
        "\u{F72D}" => Some(b"\x1b[6~"),  // PageDown
        "\u{F728}" => Some(b"\x1b[3~"),  // Delete (forward)
        "\u{F704}" => Some(b"\x1bOP"),   // F1
        "\u{F705}" => Some(b"\x1bOQ"),   // F2
        "\u{F706}" => Some(b"\x1bOR"),   // F3
        "\u{F707}" => Some(b"\x1bOS"),   // F4
        "\u{F708}" => Some(b"\x1b[15~"), // F5
        "\u{F709}" => Some(b"\x1b[17~"), // F6
        "\u{F70A}" => Some(b"\x1b[18~"), // F7
        "\u{F70B}" => Some(b"\x1b[19~"), // F8
        "\u{F70C}" => Some(b"\x1b[20~"), // F9
        "\u{F70D}" => Some(b"\x1b[21~"), // F10
        "\u{F70E}" => Some(b"\x1b[23~"), // F11
        "\u{F70F}" => Some(b"\x1b[24~"), // F12
        _ => None,
    };
    if let Some(seq) = special {
        return seq.to_vec();
    }

    // Slint sometimes sends `\u{0008}` for Backspace; terminals expect DEL.
    if key == "\u{0008}" {
        return vec![0x7f];
    }

    // Slint encodes Key::Return as "\n" (U+000A, LF).  Every real terminal
    // emulator (xterm, WezTerm, PuTTY …) sends 0x0D (CR) for Enter because
    // that is what a physical keyboard generates over a serial line.  bash/
    // readline happens to accept LF too, but ncurses apps in raw mode (nano,
    // vim command-line, passwd prompts …) strictly require CR to confirm input.
    // Ctrl+J (ctrl=true, "\n") intentionally stays 0x0A — it is a distinct
    // control character in some applications.
    if key == "\n" && !ctrl && !alt {
        return vec![0x0d];
    }

    // Empty text (e.g. the Ctrl/Shift/Alt key press itself) — nothing to send.
    if key.is_empty() {
        return vec![];
    }

    // --- Ctrl + letter: synthesise C0 control character --------------------
    // Two cases:
    //   A) Platform already encoded the control char in `key` (e.g. "\x18" for
    //      Ctrl+X on some Linux/macOS builds). Pass through directly.
    //   B) Platform sends the letter ("x") with modifiers.control=true.
    //      We synthesise the C0 code ourselves.
    if ctrl {
        // Case A: key is already a C0 control character (0x01..0x1F, not ESC).
        if let Some(c) = key.chars().next() {
            let cp = c as u32;
            if key.chars().count() == 1 && (0x01..=0x1f).contains(&cp) {
                return vec![cp as u8];
            }
        }
        // Case B: letter + ctrl modifier.
        if let Some(c) = key.chars().next() {
            if key.chars().count() == 1 {
                let upper = c.to_ascii_uppercase() as u8;
                let ctrl_char: Option<u8> = match upper {
                    b'A'..=b'Z' => Some(upper - b'A' + 1),      // Ctrl+A=\x01 … Ctrl+Z=\x1A
                    b'[' => Some(0x1b),                           // Ctrl+[ = ESC
                    b'\\' => Some(0x1c),
                    b']' => Some(0x1d),
                    b'^' => Some(0x1e),
                    b'_' => Some(0x1f),
                    b'@' => Some(0x00),
                    _ => None,
                };
                if let Some(byte) = ctrl_char {
                    return vec![byte];
                }
            }
        }
    }

    // --- Skip unknown Private Use Area code points -------------------------
    if key.chars().any(|c| (0xE000..=0xF8FF).contains(&(c as u32))) {
        return vec![];
    }

    // --- Alt + key: prefix with ESC ----------------------------------------
    if alt && !ctrl {
        let mut bytes = vec![0x1b];
        bytes.extend_from_slice(key.as_bytes());
        return bytes;
    }

    // --- Everything else: send UTF-8 bytes as-is ---------------------------
    // This covers printable characters, \r (Enter), \t (Tab), \x1b (Escape),
    // and any C0 control chars the platform already encoded in `key`.
    key.as_bytes().to_vec()
}

/// Windows-only: returns `true` when the physical Backspace key (VK_BACK) is
/// currently "down" according to `GetKeyState`.
///
/// Used to distinguish real Backspace key presses from synthetic WM_CHAR 0x08
/// events injected by IME drivers (Baidu Pinyin, etc.) when they cancel an
/// in-flight composition.  For a real Backspace, WM_KEYDOWN VK_BACK precedes
/// WM_CHAR 0x08, so GetKeyState returns "down".  For an IME-synthesised
/// Backspace, no VK_BACK keydown was queued, so GetKeyState returns "up".
#[cfg(windows)]
fn is_vk_back_down() -> bool {
    #[allow(non_snake_case)]
    extern "system" {
        fn GetKeyState(nVirtKey: i32) -> i16;
    }
    const VK_BACK: i32 = 0x08;
    unsafe { (GetKeyState(VK_BACK) as u16) & 0x8000 != 0 }
}

/// Windows-only: returns `true` when the letter key for a C0 control code
/// is currently "down" according to `GetKeyState`.
///
/// `GetKeyState` is synchronised with the Windows message queue: its value
/// reflects the state as of the *last message processed by this thread*.
/// When we are called from within a `WM_CHAR` dispatch:
///
/// * **Real Ctrl+Q**: `WM_KEYDOWN VK_Q` was dequeued and processed just
///   before `WM_CHAR 0x11`, so `GetKeyState(VK_Q)` returns "down". ✓
/// * **Synthetic injection** (Aula F99 / Baidu Pinyin tap-Left-Ctrl):
///   the driver posts `WM_CHAR 0x11` directly — no `WM_KEYDOWN VK_Q` was
///   ever in the queue — so `GetKeyState(VK_Q)` returns "up". → dropped ✓
///
/// `cp` is the C0 code point (0x01 = Ctrl+A … 0x1A = Ctrl+Z).
/// Returns `true` (allow) for code points outside 0x01–0x1A (e.g. ESC).
#[cfg(windows)]
fn c0_letter_key_down(cp: u32) -> bool {
    if !(0x01..=0x1a).contains(&cp) {
        return true; // Not a Ctrl+letter — don't filter.
    }
    let vk = (cp + 0x40) as i32; // 0x01→0x41 ('A') … 0x11→0x51 ('Q') …
    #[allow(non_snake_case)]
    extern "system" {
        fn GetKeyState(nVirtKey: i32) -> i16;
    }
    unsafe { (GetKeyState(vk) as u16) & 0x8000 != 0 }
}

/// A coloured, cursor-annotated snapshot ready for the Slint terminal grid.
struct BuiltScreen {
    spans: Vec<TermSpan>,
    cursor_row: i32,
    cursor_col: i32,
    rows_used: i32,
    is_alt: bool,
}

/// One coloured run within a line (its grid row is assigned at render time).
/// Colours are stored as raw vt100::Color so the palette (dark vs. light)
/// can be applied at render time rather than at history-capture time.
/// This lets a theme switch retroactively recolour the entire scrollback.
#[derive(Clone)]
struct HistSpan {
    text: String,
    fg: vt100::Color,
    bg: vt100::Color,
    bold: bool,
    col: i32,
    cells: i32,
}

/// A rendered line: plain text (one char per cell, for find/selection) + runs.
type Line = (String, Vec<HistSpan>);

/// Per-session scrollback cap (recycled on clear / tab close).
const MAX_HISTORY: usize = 100_000;

/// Build one screen row into `(plain_text, coloured_runs)`.  `plain` carries one
/// char per cell (space for blanks) so a char index equals the grid column.
/// Effective (contents, fg, bg, bold) for one grid cell, applying reverse-video.
/// `contents` is empty for wide-glyph continuation cells, and " " for blanks.
fn cell_attrs(
    screen: &vt100::Screen,
    r: u16,
    c: u16,
) -> (String, vt100::Color, vt100::Color, bool, bool) {
    match screen.cell(r, c) {
        Some(cell) => {
            let (mut fg, mut bg) = (cell.fgcolor(), cell.bgcolor());
            if cell.inverse() {
                std::mem::swap(&mut fg, &mut bg);
            }
            let s = cell.contents();
            if cell.is_wide_continuation() {
                (String::new(), fg, bg, cell.bold(), true)
            } else {
                let s = if s.is_empty() { " ".to_string() } else { s };
                (s, fg, bg, cell.bold(), false)
            }
        }
        None => (
            " ".to_string(),
            vt100::Color::Default,
            vt100::Color::Default,
            false,
            false,
        ),
    }
}

fn push_plain_cell(plain: &mut String, contents: &str, wide_cont: bool) {
    if wide_cont {
        plain.push(' ');
        return;
    }
    plain.push(contents.chars().next().unwrap_or(' '));
}

fn cell_text_width(contents: &str) -> u16 {
    let Some(ch) = contents.chars().next() else {
        return 0;
    };
    unicode_width::UnicodeWidthChar::width(ch)
        .unwrap_or(0)
        .min(2) as u16
}

fn text_uses_cjk_fallback(text: &str) -> bool {
    text.chars().any(|ch| {
        matches!(
            ch as u32,
            0x3000..=0x303f // CJK symbols and punctuation: 、。
                | 0x3400..=0x4dbf // CJK Extension A
                | 0x4e00..=0x9fff // CJK unified ideographs
                | 0xf900..=0xfaff // CJK compatibility ideographs
                | 0xfe10..=0xfe1f // vertical forms
                | 0xfe30..=0xfe4f // CJK compatibility forms
                | 0xff00..=0xffef // halfwidth/fullwidth forms: ：，
        )
    })
}

fn starts_wide_cell(
    screen: &vt100::Screen,
    r: u16,
    c: u16,
    cols: u16,
    contents: &str,
) -> bool {
    c + 1 < cols
        && (cell_text_width(contents) >= 2
            || screen
                .cell(r, c + 1)
                .map(|next| next.is_wide_continuation())
                .unwrap_or(false))
}

fn cell_display_width(contents: &str, wide_cont: bool, starts_wide: bool) -> u16 {
    if wide_cont {
        0
    } else if starts_wide {
        2
    } else {
        cell_text_width(contents).max(1)
    }
}

fn visual_col_for_raw_col(screen: &vt100::Screen, r: u16, raw_col: u16, cols: u16) -> i32 {
    let mut c = 0u16;
    let mut visual = 0u16;
    while c < raw_col.min(cols) && visual < cols {
        let (s, _, _, _, wide_cont) = cell_attrs(screen, r, c);
        let starts_wide = !wide_cont && starts_wide_cell(screen, r, c, cols, &s);
        visual = visual
            .saturating_add(cell_display_width(&s, wide_cont, starts_wide))
            .min(cols);
        c += 1;
    }
    visual as i32
}

fn build_row(screen: &vt100::Screen, r: u16, cols: u16) -> Line {
    let mut plain = String::with_capacity(cols as usize);
    let mut runs: Vec<HistSpan> = Vec::new();
    let mut c = 0u16;
    let mut visual_col = 0u16;
    while c < cols && visual_col < cols {
        let (s, fg, bg, bold, wide_cont) = cell_attrs(screen, r, c);
        let starts_wide = !wide_cont && starts_wide_cell(screen, r, c, cols, &s);
        // Group consecutive simple cells that share fg + bg + bold into one run.
        // Wide glyphs are isolated to their own span so font fallback cannot
        // shift following ASCII text away from the vt100 cell grid.
        let start_col = visual_col;
        let mut text = if wide_cont { String::new() } else { s.clone() };
        push_plain_cell(&mut plain, &s, wide_cont);
        visual_col += 1;
        if starts_wide && visual_col < cols {
            plain.push(' ');
            visual_col += 1;
        }
        c += 1;
        while c < cols && visual_col < cols {
            let (cs, cfg, cbg, cbold, c_wide_cont) = cell_attrs(screen, r, c);
            if starts_wide {
                if c_wide_cont {
                    c += 1;
                }
                break;
            }
            if cfg != fg || cbg != bg || cbold != bold {
                break;
            }
            let bg_default = matches!(bg, vt100::Color::Default);
            let text_is_blank = text.chars().all(|ch| ch == ' ');
            let cell_is_blank = !c_wide_cont && cs == " ";
            if bg_default && (text_is_blank != cell_is_blank) {
                break;
            }
            if !c_wide_cont && starts_wide_cell(screen, r, c, cols, &cs) {
                break;
            }
            push_plain_cell(&mut plain, &cs, c_wide_cont);
            visual_col += 1;
            if !c_wide_cont {
                text.push_str(&cs);
            }
            c += 1;
        }
        let cells = (visual_col - start_col) as i32;
        let is_blank = text.chars().all(|ch| ch == ' ');
        let bg_default = matches!(bg, vt100::Color::Default);
        // Skip runs that contribute nothing visible: blank text *and* default bg.
        if is_blank && bg_default {
            continue;
        }
        runs.push(HistSpan {
            text,
            fg, // raw vt100::Color — converted at render time with the live palette
            bg,
            bold,
            col: start_col as i32,
            cells,
        });
    }
    (plain, runs)
}

/// Detect how many lines scrolled off the top between two screen snapshots by
/// finding the vertical shift `k` that best aligns `prev` onto `curr` (longest
/// top-anchored run of equal plain-text lines).  `k` lines left the top.
fn detect_scroll(prev: &[Line], curr: &[Line]) -> usize {
    let mut best_k = 0usize;
    let mut best_len = 0usize;
    for k in 0..prev.len() {
        let mut p = 0usize;
        while k + p < prev.len() && p < curr.len() && prev[k + p].0 == curr[p].0 {
            p += 1;
        }
        if p > best_len {
            best_len = p;
            best_k = k;
        }
    }
    best_k
}

impl TermBuffer {
    // ---- Absolute-coordinate selection helpers (#18 follow-up) -------------
    //
    // The "combined" buffer is `history` (oldest first) followed by the live
    // screen rows.  A visible window of `rows` rows looks at a slice of it whose
    // top index depends on whether we're at the live bottom or scrolled up.

    fn mouse_tracking_enabled(&self) -> bool {
        !matches!(
            self.parser.screen().mouse_protocol_mode(),
            vt100::MouseProtocolMode::None
        )
    }

    /// Live screen rows plus the count of non-blank ones at the top.
    fn live_rows(&self) -> (Vec<Line>, usize) {
        let s = self.parser.screen();
        let (rows, cols) = s.size();
        let live: Vec<Line> = (0..rows).map(|r| build_row(s, r, cols)).collect();
        let used = live
            .iter()
            .rposition(|(_, runs)| !runs.is_empty())
            .map(|i| i + 1)
            .unwrap_or(0);
        (live, used)
    }

    /// Absolute combined-row index of the top visible row for the current view.
    fn view_top_abs(&self, live_used: usize) -> usize {
        let rows = self.parser.screen().size().0 as usize;
        let hist_len = self.history.len();
        if self.view_offset == 0 {
            // Live view: visible row 0 is live screen row 0 = combined[hist_len].
            hist_len
        } else {
            let combined_len = hist_len + live_used;
            combined_len.saturating_sub(rows + self.view_offset)
        }
    }

    /// Map a visible row (0..rows) to its absolute combined-row index.
    fn vis_to_abs(&self, vis_row: u16) -> usize {
        let (_, live_used) = self.live_rows();
        self.view_top_abs(live_used) + vis_row as usize
    }

    /// Highlight rectangles for the current selection, clipped to the visible
    /// window of the current view.
    fn selection_rects_visible(&self, cols: u16) -> Vec<TermMatch> {
        let (Some((ar, ac)), Some((fr, fc))) = (self.sel_anchor, self.sel_focus) else {
            return Vec::new();
        };
        let (lo_r, lo_c, hi_r, hi_c) = if (ar, ac) <= (fr, fc) {
            (ar, ac, fr, fc)
        } else {
            (fr, fc, ar, ac)
        };
        if (lo_r, lo_c) == (hi_r, hi_c) {
            return Vec::new();
        }
        let (_, live_used) = self.live_rows();
        let top = self.view_top_abs(live_used);
        let rows = self.parser.screen().size().0;
        let mut out = Vec::new();
        for vis in 0..rows {
            let abs = top + vis as usize;
            if abs < lo_r || abs > hi_r {
                continue;
            }
            let (c0, c1) = if abs == lo_r && abs == hi_r {
                (lo_c.min(hi_c), lo_c.max(hi_c))
            } else if abs == lo_r {
                (lo_c, cols.saturating_sub(1))
            } else if abs == hi_r {
                (0, hi_c)
            } else {
                (0, cols.saturating_sub(1))
            };
            out.push(TermMatch {
                row: vis as i32,
                col: c0 as i32,
                len: (c1.saturating_sub(c0) + 1) as i32,
            });
        }
        out
    }

    /// Extract the selected text from the combined buffer (whole selection,
    /// even the parts currently scrolled out of view).
    fn extract_selection_text(&self) -> String {
        let (Some((ar, ac)), Some((fr, fc))) = (self.sel_anchor, self.sel_focus) else {
            return String::new();
        };
        let (lo_r, lo_c, hi_r, hi_c) = if (ar, ac) <= (fr, fc) {
            (ar, ac, fr, fc)
        } else {
            (fr, fc, ar, ac)
        };
        let (live, live_used) = self.live_rows();
        let hist_len = self.history.len();
        let combined_len = hist_len + live_used;
        // Clamp into real content so a focus parked on a blank row below the
        // prompt doesn't emit trailing empty lines.
        let hi_r = hi_r.min(combined_len.saturating_sub(1));
        let mut out = String::new();
        for r in lo_r..=hi_r {
            let line: &str = if r < hist_len {
                &self.history[r].0
            } else if r - hist_len < live.len() {
                &live[r - hist_len].0
            } else {
                ""
            };
            let chars: Vec<char> = line.chars().collect();
            let (c0, c1) = if r == lo_r && r == hi_r {
                (lo_c.min(hi_c), lo_c.max(hi_c))
            } else if r == lo_r {
                (lo_c, u16::MAX)
            } else if r == hi_r {
                (0, hi_c)
            } else {
                (0, u16::MAX)
            };
            let c0 = (c0 as usize).min(chars.len());
            let c1 = ((c1 as usize).saturating_add(1)).min(chars.len());
            let seg: String = if c0 < c1 {
                chars[c0..c1].iter().collect()
            } else {
                String::new()
            };
            out.push_str(seg.trim_end());
            if r != hi_r {
                out.push('\n');
            }
        }
        out
    }

    /// Feed bytes to vt100 and capture scrolled-off lines into history.
    ///
    /// We detect scroll by diffing the screen before/after a `process`, which
    /// can only recover up to one screen of shift per call.  A single large
    /// burst can scroll many screens at once, so we split the input at newline
    /// boundaries into batches of at most ~half a screen of lines and capture
    /// after each — that way no batch ever scrolls more than the diff can see,
    /// and nothing is lost.  (Splitting only on `\n` is safe: VT escape
    /// sequences never contain a newline.)
    fn ingest(&mut self, raw: &[u8]) {
        // Rewrite HVP (`ESC [ … f`) → CUP (`ESC [ … H`) so vt100 (which only
        // implements `H`) honours btop/htop's absolute cursor positioning.
        let bytes = self.rewrite_hvp(raw);
        let bytes = &bytes[..];
        let rows = self.parser.screen().size().0 as usize;
        let batch_lines = (rows / 2).max(1);
        let mut start = 0usize;
        let mut nl = 0usize;
        for i in 0..bytes.len() {
            if bytes[i] == b'\n' {
                nl += 1;
                if nl >= batch_lines {
                    self.ingest_chunk(&bytes[start..=i]);
                    start = i + 1;
                    nl = 0;
                }
            }
        }
        if start < bytes.len() {
            self.ingest_chunk(&bytes[start..]);
        }
    }

    /// Translate every CSI sequence terminated by `f` (HVP) into the identical
    /// sequence terminated by `H` (CUP).  The scanner state persists across
    /// calls, so a sequence split across read chunks is still handled.  Only the
    /// final byte of a CSI sequence is ever touched; text bytes pass through.
    fn rewrite_hvp(&mut self, input: &[u8]) -> Vec<u8> {
        let mut out = Vec::with_capacity(input.len());
        for &b in input {
            match self.csi_state {
                CsiState::Normal => {
                    if b == 0x1b {
                        self.csi_state = CsiState::Esc;
                    }
                    out.push(b);
                }
                CsiState::Esc => {
                    if b == b'[' {
                        self.csi_state = CsiState::Csi;
                    } else {
                        // Not a CSI (could be another ESC, OSC, etc.).  Re-arm on
                        // a fresh ESC, otherwise fall back to normal text.
                        self.csi_state = if b == 0x1b { CsiState::Esc } else { CsiState::Normal };
                    }
                    out.push(b);
                }
                CsiState::Csi => {
                    // Final bytes are 0x40..=0x7e; params/intermediates are
                    // 0x20..=0x3f.  Rewrite an `f` final into `H`.
                    if (0x40..=0x7e).contains(&b) {
                        out.push(if b == b'f' { b'H' } else { b });
                        self.csi_state = CsiState::Normal;
                    } else {
                        out.push(b);
                    }
                }
            }
        }
        out
    }

    /// Process one bounded batch and capture any lines that scrolled off the top
    /// (skipped for alt-screen programs like vim/nano).
    fn ingest_chunk(&mut self, bytes: &[u8]) {
        // Detect full-screen-clear sequences *before* processing so we can
        // suppress history for programs that redraw without alt-screen (e.g.
        // btop configured with `alt-screen = false`).
        // We look for \033[H (cursor-home) and \033[2J / \033[J (erase display)
        // as indicators that the program is doing a full-screen refresh.
        let has_cursor_home   = bytes.windows(3).any(|w| w == b"\x1b[H");
        let has_erase_display = bytes.windows(4).any(|w| w == b"\x1b[2J")
                             || bytes.windows(3).any(|w| w == b"\x1b[J");
        let is_fullscreen_refresh = has_cursor_home && has_erase_display;

        self.parser.process(bytes);
        let (is_alt, rows, cols) = {
            let s = self.parser.screen();
            let (r, c) = s.size();
            (s.alternate_screen(), r, c)
        };
        if is_alt {
            // Alt-screen programs own their live frame, so do not capture their
            // redraws into normal scrollback. Keep view_offset intact: when the
            // user holds Shift and scrolls inside tmux, they are intentionally
            // viewing local history.
            self.prev.clear();
            return;
        }
        if is_fullscreen_refresh {
            // Non-alt-screen full-screen refresh (btop, htop with alt disabled…).
            // Don't capture lines into history; they'd mix with the next frame.
            self.view_offset = 0;
            self.prev.clear();
            return;
        }
        let curr: Vec<Line> = {
            let s = self.parser.screen();
            (0..rows).map(|r| build_row(s, r, cols)).collect()
        };
        if !self.prev.is_empty() {
            let k = detect_scroll(&self.prev, &curr);
            for line in self.prev.iter().take(k) {
                self.history.push(line.clone());
            }
            if self.history.len() > MAX_HISTORY {
                let drop = self.history.len() - MAX_HISTORY;
                self.history.drain(0..drop);
            }
        }
        self.prev = curr;
    }

    /// Render the terminal grid for the current scrollback `view_offset`
    /// (0 = live).  Caches the displayed plain text for find/selection.
    fn render(&mut self) -> BuiltScreen {
        let (is_alt, rows, cols, cur_row, cur_col) = {
            let s = self.parser.screen();
            let (r, c) = s.size();
            let (cr, cc) = s.cursor_position();
            (s.alternate_screen(), r, c, cr, cc)
        };

        // --- Live view: render the current grid.  Alt-screen stays live unless
        // the user explicitly scrolled local history with Shift+wheel.
        if self.view_offset == 0 {
            let mut spans = Vec::new();
            let mut displayed = Vec::with_capacity(rows as usize);
            let mut last_content = 0i32;
            let s = self.parser.screen();
            let cursor_col = visual_col_for_raw_col(s, cur_row, cur_col, cols);
            for r in 0..rows {
                let (plain, runs) = build_row(s, r, cols);
                if !runs.is_empty() {
                    last_content = r as i32;
                }
                for hs in runs {
                    let use_fallback_font = text_uses_cjk_fallback(&hs.text);
                    spans.push(TermSpan {
                        text: hs.text.into(),
                        fg: vt_color_to_slint(hs.fg, hs.bold, self.is_dark),
                        bg: vt_bg_to_slint(hs.bg, self.is_dark),
                        bold: hs.bold,
                        use_fallback_font,
                        row: r as i32,
                        col: hs.col,
                        cells: hs.cells,
                    });
                }
                displayed.push(plain.trim_end().to_string());
            }
            self.displayed_text = displayed;
            let rows_used = if is_alt { rows as i32 } else { last_content + 1 };
            return BuiltScreen {
                spans,
                cursor_row: cur_row as i32,
                cursor_col,
                rows_used,
                is_alt,
            };
        }

        // --- Scrolled view: window into history ++ live content -------------
        let live: Vec<Line> = {
            let s = self.parser.screen();
            (0..rows).map(|r| build_row(s, r, cols)).collect()
        };
        let live_used = live
            .iter()
            .rposition(|(_, r)| !r.is_empty())
            .map(|i| i + 1)
            .unwrap_or(0);
        let hist_len = self.history.len();
        let combined_len = hist_len + live_used;
        let win = rows as usize;
        let start = combined_len.saturating_sub(win + self.view_offset);
        let end = (start + win).min(combined_len);

        let mut spans = Vec::new();
        let mut displayed = Vec::with_capacity(win);
        for (d, idx) in (start..end).enumerate() {
            let line: &Line = if idx < hist_len {
                &self.history[idx]
            } else {
                &live[idx - hist_len]
            };
            for hs in &line.1 {
                spans.push(TermSpan {
                    text: hs.text.clone().into(),
                    fg: vt_color_to_slint(hs.fg, hs.bold, self.is_dark),
                    bg: vt_bg_to_slint(hs.bg, self.is_dark),
                    bold: hs.bold,
                    use_fallback_font: text_uses_cjk_fallback(&hs.text),
                    row: d as i32,
                    col: hs.col,
                    cells: hs.cells,
                });
            }
            displayed.push(line.0.trim_end().to_string());
        }
        while displayed.len() < win {
            displayed.push(String::new());
        }
        self.displayed_text = displayed;
        BuiltScreen {
            spans,
            cursor_row: -1, // hide the live cursor while viewing history
            cursor_col: 0,
            rows_used: win as i32,
            is_alt: false,
        }
    }
}

/// 16-colour ANSI palette for **dark** terminals (VS Code "Dark+" values).
const ANSI16_DARK: [(u8, u8, u8); 16] = [
    (0x00, 0x00, 0x00), // 0  black
    (0xcd, 0x31, 0x31), // 1  red
    (0x0d, 0xbc, 0x79), // 2  green
    (0xe5, 0xe5, 0x10), // 3  yellow
    (0x24, 0x72, 0xc8), // 4  blue
    (0xbc, 0x3f, 0xbc), // 5  magenta
    (0x11, 0xa8, 0xcd), // 6  cyan
    (0xe5, 0xe5, 0xe5), // 7  white        (light grey on dark bg)
    (0x66, 0x66, 0x66), // 8  bright black
    (0xf1, 0x4c, 0x4c), // 9  bright red
    (0x23, 0xd1, 0x8b), // 10 bright green
    (0xf5, 0xf5, 0x43), // 11 bright yellow
    (0x3b, 0x8e, 0xea), // 12 bright blue
    (0xd6, 0x70, 0xd6), // 13 bright magenta
    (0x29, 0xb8, 0xdb), // 14 bright cyan
    (0xff, 0xff, 0xff), // 15 bright white
];

/// 16-colour ANSI palette for **light** terminal **foreground** (text) use.
///
/// On a near-white (#fafafa) background, the standard "white" (slot 7) and
/// "bright white" (slot 15) are nearly invisible.  We remap them to dark greys
/// so `ls`, `git` and other tools that use colour 7 for regular text stay
/// perfectly readable.  Saturated hues are darkened for contrast.
const ANSI16_LIGHT: [(u8, u8, u8); 16] = [
    (0x1c, 0x1c, 0x1e), // 0  black        → Apple near-black
    (0xc0, 0x39, 0x2b), // 1  red
    (0x1a, 0x7f, 0x37), // 2  green        → darker for white bg
    (0x85, 0x64, 0x04), // 3  yellow       → dark amber, readable
    (0x04, 0x51, 0xa5), // 4  blue         → VS Code light blue
    (0x80, 0x00, 0x80), // 5  magenta
    (0x0e, 0x72, 0x5c), // 6  cyan         → darker teal
    (0x3a, 0x3a, 0x3c), // 7  white        → dark grey (was 0xe5e5e5, near-invisible)
    (0x55, 0x55, 0x55), // 8  bright black
    (0xe7, 0x4c, 0x3c), // 9  bright red
    (0x27, 0xae, 0x60), // 10 bright green
    (0xd4, 0xac, 0x0d), // 11 bright yellow
    (0x2e, 0x86, 0xc1), // 12 bright blue
    (0x9b, 0x59, 0xb6), // 13 bright magenta
    (0x1a, 0xbc, 0x9c), // 14 bright cyan
    (0x2c, 0x2c, 0x2e), // 15 bright white → dark (was 0xffffff, near-invisible)
];

/// 16-colour ANSI palette for **light** terminal **background** (fill) use.
///
/// When TUI programs (btop, htop, vim) paint cell backgrounds in light mode,
/// each colour maps to a light-tinted variant so the overall UI feels light.
/// "Black" (slot 0) becomes a very light grey rather than near-black, so
/// dark-background TUI apps naturally inherit a light appearance.  Foreground
/// text always uses `ANSI16_LIGHT` so readability is unaffected.
const ANSI16_LIGHT_BG: [(u8, u8, u8); 16] = [
    (0xe8, 0xe8, 0xed), // 0  black        → Apple system-grey-6 (very light)
    (0xff, 0xd5, 0xd5), // 1  red          → light rose
    (0xd5, 0xf5, 0xd5), // 2  green        → light mint
    (0xff, 0xf8, 0xd5), // 3  yellow       → light cream
    (0xd5, 0xe8, 0xf8), // 4  blue         → light sky
    (0xf5, 0xd5, 0xf5), // 5  magenta      → light lilac
    (0xd5, 0xf5, 0xf8), // 6  cyan         → light aqua
    (0xf5, 0xf5, 0xf7), // 7  white        → Apple bg (near-white)
    (0xd1, 0xd1, 0xd6), // 8  bright black → Apple system-grey-4
    (0xff, 0xbe, 0xbe), // 9  bright red   → light salmon
    (0xbe, 0xf5, 0xbe), // 10 bright green
    (0xf5, 0xf5, 0xbe), // 11 bright yellow
    (0xbe, 0xdd, 0xff), // 12 bright blue  → light periwinkle
    (0xf0, 0xbe, 0xff), // 13 bright magenta → light violet
    (0xbe, 0xf5, 0xff), // 14 bright cyan
    (0xff, 0xff, 0xff), // 15 bright white → white
];

/// Convert a vt100 foreground colour (+ bold) to a Slint colour.
/// Bold + a base colour (0–7) maps to the bright variant (8–15), matching
/// how terminals render `ls --color` (bold-green executables, bold-blue dirs).
///
/// In light mode, true-colour RGB foregrounds that are light (HSL lightness
/// ≥ 0.55) are darkened so they remain readable on a near-white background.
fn vt_color_to_slint(color: vt100::Color, bold: bool, is_dark: bool) -> slint::Color {
    let (r, g, b) = match color {
        vt100::Color::Default => {
            if is_dark { (0xd4, 0xd4, 0xd4) } else { (0x2d, 0x2d, 0x2f) }
        }
        vt100::Color::Idx(i) => idx_to_rgb(i, bold, is_dark),
        vt100::Color::Rgb(r, g, b) => {
            if is_dark { (r, g, b) } else { darken_light_fg(r, g, b) }
        }
    };
    slint::Color::from_rgb_u8(r, g, b)
}

/// In light mode, remap light true-colour foregrounds to dark so they are
/// readable on a near-white background.  Colours already dark (L < 0.55)
/// pass through unchanged.
fn darken_light_fg(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
    let (h, s, l) = rgb_to_hsl(r, g, b);
    if l < 0.55 {
        return (r, g, b);
    }
    // L=0.55 → 0.40 (readable dark grey), L=1.0 (white) → ~0.15 (near-black).
    let new_l = (0.40 - (l - 0.55) * 0.56).max(0.10);
    hsl_to_rgb(h, s, new_l)
}

/// Convert a vt100 *background* colour to Slint.  The default background maps
/// to fully transparent so we don't paint a fill over the terminal's own bg.
/// Non-default backgrounds (btop/htop bars, selected rows) become opaque.
///
/// In light mode:
/// - ANSI 16 colours use `ANSI16_LIGHT_BG` (light pastels).
/// - True-colour RGB backgrounds that are dark (HSL lightness < 0.45) are
///   remapped to light pastels so programs like btop feel light-themed.
fn vt_bg_to_slint(color: vt100::Color, is_dark: bool) -> slint::Color {
    match color {
        vt100::Color::Default => slint::Color::from_argb_u8(0, 0, 0, 0), // transparent
        vt100::Color::Idx(i) => {
            let (r, g, b) = idx_to_rgb_bg(i, is_dark);
            slint::Color::from_rgb_u8(r, g, b)
        }
        vt100::Color::Rgb(r, g, b) => {
            if is_dark {
                slint::Color::from_rgb_u8(r, g, b)
            } else {
                let (nr, ng, nb) = lighten_dark_bg(r, g, b);
                slint::Color::from_rgb_u8(nr, ng, nb)
            }
        }
    }
}

/// In light mode, remap dark true-colour backgrounds to light pastels.
/// Colours whose HSL lightness is already ≥ 0.45 pass through unchanged
/// (the program chose a light colour deliberately).
fn lighten_dark_bg(r: u8, g: u8, b: u8) -> (u8, u8, u8) {
    let (h, s, l) = rgb_to_hsl(r, g, b);
    if l >= 0.45 {
        return (r, g, b);
    }
    // Remap: darkest (l≈0) → very light (l≈0.92); l=0.45 → l≈0.84.
    // Reduce saturation to pastel so colours don't look garish on white.
    let new_l = 0.92 - l * 0.18;
    let new_s = (s * 0.35).min(0.25);
    hsl_to_rgb(h, new_s, new_l)
}

fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
    let r = r as f32 / 255.0;
    let g = g as f32 / 255.0;
    let b = b as f32 / 255.0;
    let max = r.max(g).max(b);
    let min = r.min(g).min(b);
    let l = (max + min) / 2.0;
    if (max - min).abs() < 1e-6 {
        return (0.0, 0.0, l);
    }
    let d = max - min;
    let s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
    let h = if (max - r).abs() < 1e-6 {
        (g - b) / d + if g < b { 6.0 } else { 0.0 }
    } else if (max - g).abs() < 1e-6 {
        (b - r) / d + 2.0
    } else {
        (r - g) / d + 4.0
    } / 6.0;
    (h, s, l)
}

fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
    if s < 1e-6 {
        let v = (l * 255.0).round() as u8;
        return (v, v, v);
    }
    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
    let p = 2.0 * l - q;
    let hue = |mut t: f32| -> f32 {
        if t < 0.0 { t += 1.0; }
        if t > 1.0 { t -= 1.0; }
        if t < 1.0 / 6.0 { return p + (q - p) * 6.0 * t; }
        if t < 0.5 { return q; }
        if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
        p
    };
    (
        (hue(h + 1.0 / 3.0) * 255.0).round() as u8,
        (hue(h) * 255.0).round() as u8,
        (hue(h - 1.0 / 3.0) * 255.0).round() as u8,
    )
}

/// Map an xterm-256 palette index to RGB (16 ANSI + 6×6×6 cube + grayscale).
fn idx_to_rgb(i: u8, bold: bool, is_dark: bool) -> (u8, u8, u8) {
    let i = if bold && i < 8 { i + 8 } else { i };
    let palette = if is_dark { &ANSI16_DARK } else { &ANSI16_LIGHT };
    match i {
        0..=15 => palette[i as usize],
        16..=231 => {
            let n = i - 16;
            let to = |v: u8| -> u8 {
                if v == 0 { 0 } else { 55 + v * 40 }
            };
            (to(n / 36), to((n % 36) / 6), to(n % 6))
        }
        _ => {
            let v = 8 + (i - 232) * 10;
            (v, v, v)
        }
    }
}

/// Same as [`idx_to_rgb`] but for **background** fills in light mode: the 16
/// ANSI base colours use `ANSI16_LIGHT_BG` (light pastels) so TUI program
/// backgrounds feel light.  256-colour cube / grayscale are used as-is.
fn idx_to_rgb_bg(i: u8, is_dark: bool) -> (u8, u8, u8) {
    if !is_dark && i < 16 {
        return ANSI16_LIGHT_BG[i as usize];
    }
    idx_to_rgb(i, false, is_dark)
}

/// Return the parent directory of `path`.
/// "/a/b/c" → "/a/b", "/a" → "/", "/" → "/"
fn parent_path(path: &str) -> String {
    let trimmed = path.trim_end_matches('/');
    if trimmed.is_empty() {
        return "/".to_string();
    }
    match trimmed.rfind('/') {
        Some(0) => "/".to_string(),
        Some(i) => trimmed[..i].to_string(),
        None => "/".to_string(),
    }
}

#[cfg(test)]
mod command_template_tests {
    use super::*;

    #[test]
    fn extracts_parameter_names_in_slot_order() {
        assert_eq!(
            extract_command_params("kill [p#1 pid] --signal [p#2 signal] [p#1 duplicate]"),
            vec!["pid".to_string(), "signal".to_string()]
        );
    }

    #[test]
    fn renders_required_parameters() {
        assert_eq!(
            render_command_with_params(
                "kill [p#1 pid] --signal [p#2 signal]",
                &["123".to_string(), "9".to_string()]
            ),
            Some("kill 123 --signal 9".to_string())
        );
    }

    #[test]
    fn rejects_missing_required_parameter() {
        assert_eq!(
            render_command_with_params(
                "kill [p#1 pid] --signal [p#2 signal]",
                &["123".to_string(), "".to_string()]
            ),
            None
        );
    }

    #[test]
    fn leaves_out_of_range_parameter_tokens_literal() {
        assert_eq!(
            render_command_with_params("echo [p#6 ignored]", &[]),
            Some("echo [p#6 ignored]".to_string())
        );
    }
}

#[cfg(test)]
mod terminal_render_tests {
    use super::*;

    #[test]
    fn build_row_keeps_plain_text_one_char_per_cell() {
        let mut parser = vt100::Parser::new(2, 5, 0);
        parser.process("e\u{301}x".as_bytes());

        let (plain, _) = build_row(parser.screen(), 0, 5);

        assert_eq!(plain.chars().count(), 5);
        assert_eq!(plain.chars().next(), Some('e'));
    }

    #[test]
    fn build_row_counts_wide_glyph_cells_separately_from_text() {
        let mut parser = vt100::Parser::new(2, 8, 0);
        parser.process("界\x1b[31mx".as_bytes());

        let (_, runs) = build_row(parser.screen(), 0, 8);

        assert_eq!(runs[0].text, "界");
        assert_eq!(runs[0].cells, 2);
    }

    #[test]
    fn build_row_splits_wide_glyphs_out_of_ascii_runs() {
        let mut parser = vt100::Parser::new(2, 8, 0);
        parser.process("A界B".as_bytes());

        let (_, runs) = build_row(parser.screen(), 0, 8);

        assert_eq!(runs[0].text, "A");
        assert_eq!(runs[0].col, 0);
        assert_eq!(runs[0].cells, 1);
        assert_eq!(runs[1].text, "界");
        assert_eq!(runs[1].col, 1);
        assert_eq!(runs[1].cells, 2);
        assert_eq!(runs[2].text, "B");
        assert_eq!(runs[2].col, 3);
        assert_eq!(runs[2].cells, 1);
    }

    #[test]
    fn build_row_preserves_coloured_wide_glyph_position_after_default_gap() {
        let mut parser = vt100::Parser::new(2, 10, 0);
        parser.process("A  \x1b[34m界\x1b[0mB".as_bytes());

        let (_, runs) = build_row(parser.screen(), 0, 10);

        assert_eq!(runs[0].text, "A");
        assert_eq!(runs[0].col, 0);
        assert_eq!(runs[0].cells, 1);
        assert_eq!(runs[1].text, "界");
        assert_eq!(runs[1].col, 3);
        assert_eq!(runs[1].cells, 2);
        assert_eq!(runs[2].text, "B");
        assert_eq!(runs[2].col, 5);
        assert_eq!(runs[2].cells, 1);
    }

    #[test]
    fn cjk_fallback_covers_fullwidth_punctuation() {
        assert!(text_uses_cjk_fallback("：，。、"));
        assert!(!text_uses_cjk_fallback(":/,."));
    }
}

#[cfg(test)]
mod selection_tests {
    use super::*;

    fn hist_line(s: &str) -> Line {
        (s.to_string(), Vec::new())
    }

    /// A TermBuffer whose live screen (rows×cols) shows `live_lines`, with the
    /// given `history` above it, viewed at `view_offset` (0 = live bottom).
    fn make_buf(
        rows: u16,
        cols: u16,
        history: &[&str],
        live_lines: &[&str],
        view_offset: usize,
    ) -> TermBuffer {
        let mut parser = vt100::Parser::new(rows, cols, 0);
        parser.process(live_lines.join("\r\n").as_bytes());
        TermBuffer {
            parser,
            find_query: String::new(),
            is_dark: false,
            sel_anchor: None,
            sel_focus: None,
            history: history.iter().map(|s| hist_line(s)).collect(),
            prev: Vec::new(),
            view_offset,
            displayed_text: Vec::new(),
            csi_state: CsiState::Normal,
        }
    }

    #[test]
    fn vis_to_abs_maps_live_and_scrolled_consistently() {
        // history H0..H2 (3 lines), live LIVE0/LIVE1 → combined len 5.
        let live = make_buf(5, 20, &["H0", "H1", "H2"], &["LIVE0", "LIVE1"], 0);
        assert_eq!(live.vis_to_abs(0), 3, "live row 0 is first live line");
        assert_eq!(live.vis_to_abs(1), 4);

        // Scrolled to the very top (offset = history len).
        let top = make_buf(5, 20, &["H0", "H1", "H2"], &["LIVE0", "LIVE1"], 3);
        assert_eq!(top.vis_to_abs(0), 0, "top row 0 is oldest history line");
        assert_eq!(top.vis_to_abs(2), 2);
        assert_eq!(top.vis_to_abs(3), 3, "row 3 crosses into live content");
    }

    #[test]
    fn extract_spans_history_and_live() {
        let mut buf = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], 3);
        buf.sel_anchor = Some((0, 0)); // top of history
        buf.sel_focus = Some((4, 19)); // end of last live line
        assert_eq!(
            buf.extract_selection_text(),
            "HIST0\nHIST1\nHIST2\nLIVE0\nLIVE1"
        );
    }

    #[test]
    fn extract_is_view_independent() {
        // The same absolute selection copies identically whether the view is
        // scrolled to the top or sitting at the live bottom — this is the whole
        // point of the fix (a top-to-bottom selection survives auto-scrolling).
        let sel = |off| {
            let mut b = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], off);
            b.sel_anchor = Some((0, 0));
            b.sel_focus = Some((4, 19));
            b.extract_selection_text()
        };
        assert_eq!(sel(3), sel(0));
        assert_eq!(sel(3), "HIST0\nHIST1\nHIST2\nLIVE0\nLIVE1");
    }

    #[test]
    fn highlight_clipped_to_current_view() {
        // Scrolled to the top: a history selection is on-screen and highlighted.
        let mut top = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], 3);
        top.sel_anchor = Some((0, 2));
        top.sel_focus = Some((2, 4));
        let rects = top.selection_rects_visible(20);
        assert_eq!(rects.len(), 3, "rows 0,1,2 (the 3 history lines) highlighted");
        assert_eq!(rects[0].row, 0);
        assert_eq!(rects[2].row, 2);

        // At the live bottom the same history selection is scrolled off → none.
        let mut live = make_buf(5, 20, &["HIST0", "HIST1", "HIST2"], &["LIVE0", "LIVE1"], 0);
        live.sel_anchor = Some((0, 2));
        live.sel_focus = Some((2, 4));
        assert!(live.selection_rects_visible(20).is_empty());
    }
}
