//! Full-screen terminal chat UI.
//!
//! Two phases: a loading spinner while the model initializes, then
//! interactive chat. Uses `tokio::select!` to multiplex terminal events
//! with streaming LLM tokens.

// ── Think-block stripping ─────────────────────────────────────────────────────

/// Split out `<think>…</think>` blocks from a model response.
///
/// Qwen 3 emits reasoning inside `<think>` tags before the actual answer.
/// Returns `(thinking_text, visible_reply)`. Either may be empty.
pub(crate) fn strip_think_blocks(raw: &str) -> (String, String) {
    let mut thinking = String::new();
    let mut remainder = raw;

    while let Some(start) = remainder.find("<think>") {
        let before = &remainder[..start];
        if let Some(end) = remainder[start..].find("</think>") {
            let block = &remainder[start + 7..start + end];
            thinking.push_str(block.trim());
            remainder = &remainder[start + end + 8..];
            if !before.trim().is_empty() {
                // rare: text before <think> — keep it visible
                let mut combined = before.to_string();
                combined.push_str(remainder);
                return (thinking, combined.trim().to_string());
            }
        } else {
            // unclosed tag — model probably ran out of tokens
            thinking.push_str(remainder[start + 7..].trim());
            remainder = before;
            break;
        }
    }

    (thinking, remainder.trim().to_string())
}

/// The live reasoning tail to show while a streaming buffer holds only
/// `<think>` content (no visible reply yet).
///
/// Returns the last `max_lines` lines of the in-progress thinking text after
/// word-wrapping at `width` columns, so the TUI can render them under the
/// "thinking…" label and the user watches the model reason. Returns an empty
/// vec once visible text has arrived (the reply streams instead) or when
/// there is no thinking text yet (plain spinner).
pub(crate) fn streaming_think_tail(raw: &str, width: usize, max_lines: usize) -> Vec<String> {
    let (thinking, visible) = strip_think_blocks(raw);
    if thinking.is_empty() || !visible.trim().is_empty() || max_lines == 0 {
        return Vec::new();
    }

    let width = width.max(1);
    let mut wrapped: Vec<String> = Vec::new();
    for line in thinking.lines() {
        let mut current = String::new();
        for word in line.split_whitespace() {
            // hard-split words wider than the wrap column
            let mut word = word;
            while word.chars().count() > width {
                if !current.is_empty() {
                    wrapped.push(std::mem::take(&mut current));
                }
                let head: String = word.chars().take(width).collect();
                word = &word[head.len()..];
                wrapped.push(head);
            }
            if current.is_empty() {
                current.push_str(word);
            } else if current.chars().count() + 1 + word.chars().count() <= width {
                current.push(' ');
                current.push_str(word);
            } else {
                wrapped.push(std::mem::take(&mut current));
                current.push_str(word);
            }
        }
        if !current.is_empty() {
            wrapped.push(current);
        }
    }

    let skip = wrapped.len().saturating_sub(max_lines);
    wrapped.split_off(skip)
}

/// One-line dim indicator shown above a reply whose reasoning is collapsed,
/// e.g. `· thought for a bit (12 lines) — /thinking to show`.
pub(crate) fn think_indicator(think: &str) -> String {
    let count = think.lines().count().max(1);
    let noun = if count == 1 { "line" } else { "lines" };
    format!("· thought for a bit ({count} {noun}) — /thinking to show")
}

pub(crate) fn parse_rich_text_segments(text: &str) -> Vec<(String, bool)> {
    let mut segments = Vec::new();
    let mut current = String::new();
    let mut chars = text.chars().peekable();
    let mut bold = false;

    while let Some(ch) = chars.next() {
        if ch == '*' && chars.peek() == Some(&'*') {
            chars.next();
            if !current.is_empty() {
                segments.push((std::mem::take(&mut current), bold));
            }
            bold = !bold;
        } else {
            current.push(ch);
        }
    }

    if !current.is_empty() {
        segments.push((current, bold));
    }

    segments
}

// ── Tool-call display helpers ─────────────────────────────────────────────────
//
// Pure helpers for the TUI's collapsible tool-call entries. Top-level (not in
// `mod tui`) so they are testable on every target; only the Unix TUI consumes
// them, hence the non-Unix dead-code allowance.

/// Max characters for the summary half of a tool-call title line.
#[cfg_attr(not(unix), allow(dead_code))]
const TOOL_TITLE_SUMMARY_MAX: usize = 60;

/// Max characters of tool output kept for the expanded-entry result preview.
#[cfg_attr(not(unix), allow(dead_code))]
const TOOL_RESULT_PREVIEW_MAX: usize = 2000;

/// Truncate to `max_chars` characters, ending with an ellipsis when cut.
#[cfg_attr(not(unix), allow(dead_code))]
fn truncate_with_ellipsis(text: &str, max_chars: usize) -> String {
    if text.chars().count() <= max_chars {
        return text.to_string();
    }
    let mut out: String = text.chars().take(max_chars.saturating_sub(1)).collect();
    out.push('…');
    out
}

/// One-line title for a tool call, Claude Code style: the tool name plus the
/// argument that best identifies the call (`run_command · cargo test`,
/// `edit_file · src/main.rs`, `sigit · list_issues` for MCP tools). Falls back
/// to the bare tool name when the arguments are malformed or carry nothing
/// summarizable.
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn tool_title(name: &str, arguments_json: &str) -> String {
    let args: Option<serde_json::Value> = serde_json::from_str(arguments_json).ok();
    let str_arg =
        |key: &str| -> Option<String> { Some(args.as_ref()?.get(key)?.as_str()?.to_string()) };

    let (label, summary) = if let Some(rest) = name.strip_prefix("mcp__") {
        // mcp__<server>__<tool> → "<server> · <tool>"
        match rest.split_once("__") {
            Some((server, tool)) => (server.to_string(), Some(tool.to_string())),
            None => (rest.to_string(), None),
        }
    } else {
        let summary = match name {
            "run_command" => str_arg("command"),
            "read_file" | "create_file" | "edit_file" | "multi_edit" | "delete_file" => {
                str_arg("path")
            }
            "search_files" | "glob" => str_arg("pattern"),
            _ => None,
        };
        (name.to_string(), summary)
    };

    // A title is a single line: collapse any internal whitespace runs
    // (multi-line commands) before truncating.
    let summary = summary
        .map(|s| s.split_whitespace().collect::<Vec<_>>().join(" "))
        .filter(|s| !s.is_empty())
        .map(|s| truncate_with_ellipsis(&s, TOOL_TITLE_SUMMARY_MAX));

    match summary {
        Some(summary) => format!("{label} · {summary}"),
        None => label,
    }
}

/// Pretty-print a tool call's JSON arguments for the expanded view; malformed
/// JSON is shown raw.
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn pretty_tool_arguments(arguments_json: &str) -> String {
    serde_json::from_str::<serde_json::Value>(arguments_json)
        .ok()
        .and_then(|value| serde_json::to_string_pretty(&value).ok())
        .unwrap_or_else(|| arguments_json.to_string())
}

/// The tail of a tool's output for display under an expanded tool entry,
/// capped at [`TOOL_RESULT_PREVIEW_MAX`] chars with a truncation note. Display
/// only — the model always receives the full output.
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn cap_output_preview(output: &str) -> String {
    let total = output.chars().count();
    if total <= TOOL_RESULT_PREVIEW_MAX {
        return output.to_string();
    }
    let tail: String = output
        .chars()
        .skip(total - TOOL_RESULT_PREVIEW_MAX)
        .collect();
    format!("… (truncated — showing the last {TOOL_RESULT_PREVIEW_MAX} of {total} chars)\n{tail}")
}

/// The display lines of one tool-call entry. Collapsed: a single `▸ 🔧 title`
/// line. Expanded: `▾ 🔧 title`, the indented argument detail, and the result
/// preview under a `result:` label when present.
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn tool_entry_lines(
    title: &str,
    detail: &str,
    result_preview: Option<&str>,
    expanded: bool,
) -> Vec<String> {
    if !expanded {
        return vec![format!("▸ 🔧 {title}")];
    }
    let mut out = vec![format!("▾ 🔧 {title}")];
    if !detail.is_empty() {
        for line in detail.split('\n') {
            out.push(format!("    {line}"));
        }
    }
    if let Some(result) = result_preview {
        out.push("    result:".to_string());
        for line in result.split('\n') {
            out.push(format!("      {line}"));
        }
    }
    out
}

// ── Tabs ──────────────────────────────────────────────────────────────────────
//
// The top-level tab bar (GitHub Copilot CLI-style). Defined outside `mod tui`
// so the pure cycling/formatting logic is testable on every target; only the
// Unix-only TUI consumes it at runtime, hence the non-Unix dead-code gates
// (same pattern as `permissions::TUI_SESSION`).

/// The three top-level TUI tabs, cycled with the Tab key.
#[cfg_attr(not(unix), allow(dead_code))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Tab {
    /// The chat itself (default).
    Session,
    /// Saved sessions from the session store.
    History,
    /// siGit Code Cloud status and settings.
    Cloud,
}

#[cfg_attr(not(unix), allow(dead_code))]
impl Tab {
    pub(crate) const TITLES: [&'static str; 3] = ["Session", "History", "Cloud"];

    /// Session → History → Cloud → Session.
    pub(crate) fn next(self) -> Self {
        match self {
            Tab::Session => Tab::History,
            Tab::History => Tab::Cloud,
            Tab::Cloud => Tab::Session,
        }
    }

    /// Position in [`Tab::TITLES`], for the ratatui `Tabs` widget.
    pub(crate) fn index(self) -> usize {
        match self {
            Tab::Session => 0,
            Tab::History => 1,
            Tab::Cloud => 2,
        }
    }
}

/// Coarse "how long ago" label for the History tab (no date dependency).
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn format_age(age: std::time::Duration) -> String {
    let secs = age.as_secs();
    if secs < 60 {
        format!("{secs}s ago")
    } else if secs < 3_600 {
        format!("{}m ago", secs / 60)
    } else if secs < 86_400 {
        format!("{}h ago", secs / 3_600)
    } else {
        format!("{}d ago", secs / 86_400)
    }
}

/// One History-tab row: id, age, message count. `age` is `None` when the
/// file's mtime could not be read (or lies in the future).
#[cfg_attr(not(unix), allow(dead_code))]
pub(crate) fn history_row(
    id: &str,
    age: Option<std::time::Duration>,
    message_count: usize,
) -> String {
    let when = age
        .map(format_age)
        .unwrap_or_else(|| "age unknown".to_string());
    format!("{id}  ·  {when}  ·  {message_count} message(s)")
}

// ── Unix-only TUI ─────────────────────────────────────────────────────────────
//
// macOS + Linux only. Windows uses ACP mode instead.

#[cfg(unix)]
mod tui {
    use std::future::pending;
    use std::sync::Arc;
    use std::sync::mpsc as std_mpsc;

    use anyhow::Result;
    use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
    use futures::StreamExt;
    use onde::inference::{ChatEngine, SamplingConfig};

    use super::Tab;
    use crate::backend::{InferenceBackend, LocalBackend, OpenAiBackend, ToolResult, ToolSpec};
    use crate::models::{
        InferenceKind, ModelCacheHealth, ModelPickerItem, ModelSource, build_model_picker_items,
    };
    use crate::session_store::SessionEntry;
    use ratatui::{
        Frame,
        layout::{Constraint, Layout, Position},
        style::{Color, Modifier, Style},
        text::{Line, Span},
        widgets::{Block, Borders, Clear, Paragraph, Tabs, Wrap},
    };
    use tokio::sync::{mpsc, oneshot};
    use tokio::time::{Duration, Instant, interval};

    // ── Message types ─────────────────────────────────────────────────────────

    #[derive(Clone, Copy, PartialEq, Eq)]
    enum Role {
        User,
        Assistant,
        System,
        /// rainbow-colored banner art
        Banner,
        /// a collapsible tool-call entry (`text` holds the title line)
        Tool,
    }

    struct ChatMessage {
        role: Role,
        text: String,
        /// Qwen 3 reasoning extracted from `<think>` tags, if any.
        think_block: Option<String>,
        /// pretty-printed tool arguments (Role::Tool only)
        tool_detail: Option<String>,
        /// tail of the tool output, filled in when the call finishes
        /// (Role::Tool only)
        tool_result: Option<String>,
    }

    impl ChatMessage {
        fn user(text: impl Into<String>) -> Self {
            Self {
                role: Role::User,
                text: text.into(),
                think_block: None,
                tool_detail: None,
                tool_result: None,
            }
        }

        fn assistant(text: impl Into<String>) -> Self {
            let raw = text.into();
            let (think, visible) = super::strip_think_blocks(&raw);
            Self {
                role: Role::Assistant,
                text: visible,
                think_block: if think.is_empty() { None } else { Some(think) },
                tool_detail: None,
                tool_result: None,
            }
        }

        fn system(text: impl Into<String>) -> Self {
            Self {
                role: Role::System,
                text: text.into(),
                think_block: None,
                tool_detail: None,
                tool_result: None,
            }
        }

        fn banner(text: impl Into<String>) -> Self {
            Self {
                role: Role::Banner,
                text: text.into(),
                think_block: None,
                tool_detail: None,
                tool_result: None,
            }
        }

        fn tool_call(title: impl Into<String>, detail: impl Into<String>) -> Self {
            Self {
                role: Role::Tool,
                text: title.into(),
                think_block: None,
                tool_detail: Some(detail.into()),
                tool_result: None,
            }
        }
    }

    // ── Inference updates from background task ────────────────────────────────

    enum InferenceUpdate {
        /// a tool call started — show a collapsible entry in the transcript
        ToolUse {
            name: String,
            /// raw JSON arguments, pretty-printed by the UI for the expanded view
            arguments: String,
        },
        /// the most recent tool call finished; attach a display-only preview of
        /// the tail of its output (the model always gets the full output)
        ToolDone {
            output_preview: String,
        },
        /// a streamed token fragment of the assistant's reply
        Delta(String),
        /// the streamed reply is complete; commit the accumulated buffer
        StreamEnd,
        /// a complete (non-streamed) assistant reply
        Response(String),
        Error(String),
        /// the inference task wants to run a mutating tool and is paused on
        /// `reply`; the user answers with y (once) / a (session) / n (deny)
        ApprovalRequest {
            tool: String,
            /// arguments preview so the user can see what they are approving
            args: String,
            reply: oneshot::Sender<ApprovalChoice>,
        },
    }

    /// The user's answer to a tool-approval prompt. Dropping the reply channel
    /// (quit, cancel) counts as a denial on the inference side.
    enum ApprovalChoice {
        /// run this one call
        Once,
        /// run it and stop asking for this tool for the rest of the session
        Session,
        /// skip the call; the model gets an explanatory tool result
        Deny,
    }

    enum ModelLoadUpdate {
        Loaded(String),
        Error(String),
    }

    // ── App state ─────────────────────────────────────────────────────────────

    struct App {
        messages: Vec<ChatMessage>,
        input: String,
        cursor: usize,
        /// true while assistant tokens are streaming into `stream_buf`
        streaming: bool,
        stream_buf: String,
        inference_rx: Option<mpsc::Receiver<InferenceUpdate>>,
        model_load_rx: Option<mpsc::Receiver<ModelLoadUpdate>>,
        /// a tool call waiting on the user's y/a/n answer; the inference task is
        /// paused on the other end of the channel
        pending_approval: Option<(String, oneshot::Sender<ApprovalChoice>)>,
        thinking: bool,
        thinking_tick: u8,
        quit: bool,
        /// toggled periodically so the streaming cursor blinks
        blink_on: bool,
        blink_counter: u8,
        switching_model: bool,
        /// stashed until ModelLoadUpdate::Loaded applies it to `app.tool_calling`
        pending_tool_calling: Option<bool>,
        /// suppresses the spurious "disconnected" error when we drop model_load_rx on cancel
        model_load_cancelled: bool,

        // ── Loading-phase state ───────────────────────────────────────────────
        is_loading: bool,
        load_tick: u32,
        /// keeps the loading view visible so the user can read the error
        load_error: Option<String>,
        load_start: Instant,
        load_model_name: String,

        // ── Model picker state ────────────────────────────────────────────────
        show_model_picker: bool,
        model_picker_index: usize,
        model_picker_items: Vec<ModelPickerItem>,
        current_model_name: String,
        tool_calling: bool,
        /// `/thinking` — expand the model's reasoning on rendered messages.
        /// Display-only: never changes what is sent to the model or saved.
        show_thinking: bool,
        /// `/tools` — expand every tool-call entry (default: collapsed titles)
        tools_expanded: bool,

        // ── Model-switch download progress ────────────────────────────────────
        switching_model_id: Option<String>,
        /// (downloaded, expected) bytes — polled every tick during a model switch
        download_progress: Option<(u64, u64)>,

        // ── Active inference backend ──────────────────────────────────────────
        /// The backend serving inference. Swapped in place when the user picks a
        /// different model or cloud tier via `/models`.
        backend: Arc<dyn InferenceBackend>,

        // ── Tab bar state ─────────────────────────────────────────────────────
        /// Which top-level tab is showing. Inference keeps running while the
        /// user is on History/Cloud; updates land in `messages` regardless.
        active_tab: Tab,

        // History tab: saved sessions from the session store.
        history_sessions: Vec<SessionEntry>,
        history_index: usize,
        /// Session id awaiting the confirming second `d`; any other key clears it.
        history_pending_delete: Option<String>,
        /// One-shot notice shown under the session list (e.g. a failed restore).
        history_notice: Option<String>,

        // Cloud tab: status text is fetched async when the tab opens and cached.
        /// `None` while a fetch is in flight (renders as "fetching…").
        cloud_lines: Option<Vec<String>>,
        cloud_rx: Option<oneshot::Receiver<Vec<String>>>,
    }

    const BANNER_ART: &str = "\
77777777777777777777777777777777777777777777777777777777777777777777777777777777777777777777
77777777322222222222222222222222222222223777389969902208431358831999699051111177777777777777
1111111125555555555555555555555511113222311159    5002         088    3081771691111111111111
1111111111111111111111111111131136841   1482853332007    05    9043332891    400811111111111
1111111111111111111111111111111201        109    304    40     00    79      100041111111111
333333255555555555555555555552392   102   503    90    7000000005    903    0000023333333333
333333245454545454545454545433381    7600000    302    61    780    109    20009533333333333
3333333333333333333333333333333402      7001    08    761    202    902    90003333333333333
2222255555555555555555555555250899901    49    304    403    08    108    300042222222222222
2222222222222222222222222222269   106    03    901    06    505    402    000052222222222222
2222255555555555555555555555299        708    1002          80     00      90852222222222222
55555555555555555555555555555560953258000866660000051140866908666600008966900065555555555555
88888888888888888888888888888888888888888888888888888888888888888888888888888888888888888888";

    const THINKING_FRAMES: &[&str] = &["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

    fn rich_text_spans(text: &str, base_style: Style, bold_style: Style) -> Vec<Span<'static>> {
        let mut spans = Vec::new();

        for (segment, is_bold) in super::parse_rich_text_segments(text) {
            let style = if is_bold { bold_style } else { base_style };
            spans.push(Span::styled(segment, style));
        }

        if spans.is_empty() {
            spans.push(Span::styled(String::new(), base_style));
        }

        spans
    }

    impl App {
        fn new(load_model_name: String, backend: Arc<dyn InferenceBackend>) -> Self {
            let is_remote = backend.is_remote();
            let items = build_model_picker_items();
            let tool_calling = items
                .iter()
                .find(|m| m.display_name == load_model_name)
                .map(|m| m.tool_calling)
                .unwrap_or(true);
            // For a remote provider the passed-in name is authoritative; the
            // persisted local selection must not override it (or the title would
            // show an on-device model while requests go to the cloud).
            let current_model_name = if is_remote {
                load_model_name.clone()
            } else {
                crate::setup::load_selected_model_name().unwrap_or_else(|| load_model_name.clone())
            };
            Self {
                messages: Vec::new(),
                input: String::new(),
                cursor: 0,
                streaming: false,
                stream_buf: String::new(),
                inference_rx: None,
                model_load_rx: None,
                pending_approval: None,
                thinking: false,
                thinking_tick: 0,
                quit: false,
                blink_on: true,
                blink_counter: 0,
                switching_model: false,
                pending_tool_calling: None,
                model_load_cancelled: false,
                switching_model_id: None,
                download_progress: None,
                is_loading: true,
                load_tick: 0,
                load_error: None,
                load_start: Instant::now(),
                load_model_name: load_model_name.clone(),
                show_model_picker: false,
                model_picker_index: 0,
                model_picker_items: items,
                current_model_name,
                tool_calling,
                show_thinking: false,
                tools_expanded: false,
                backend,
                active_tab: Tab::Session,
                history_sessions: Vec::new(),
                history_index: 0,
                history_pending_delete: None,
                history_notice: None,
                cloud_lines: None,
                cloud_rx: None,
            }
        }

        /// Reload the History tab's session list, keeping the selection in
        /// bounds and dropping any pending delete confirmation.
        fn refresh_history(&mut self) {
            self.history_sessions = crate::session_store::list();
            self.history_index = self
                .history_index
                .min(self.history_sessions.len().saturating_sub(1));
            self.history_pending_delete = None;
        }

        /// The History entry the cursor is on, if any.
        fn selected_session(&self) -> Option<&SessionEntry> {
            self.history_sessions.get(self.history_index)
        }

        fn is_busy(&self) -> bool {
            self.is_streaming() || self.thinking || self.switching_model
        }

        fn switching_frame(&self) -> &'static str {
            let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
            THINKING_FRAMES[idx]
        }

        fn is_streaming(&self) -> bool {
            self.streaming
        }

        fn finalize_stream(&mut self) {
            self.streaming = false;
            if !self.stream_buf.is_empty() {
                let text = std::mem::take(&mut self.stream_buf);
                self.messages.push(ChatMessage::assistant(text));
            }
            self.blink_on = false;
        }

        fn push_stream_delta(&mut self, delta: &str) {
            self.streaming = true;
            self.stream_buf.push_str(delta);
            // Hide reasoning the way the rest of the app does: keep the "thinking"
            // spinner until visible (non-<think>) text appears, then show the
            // live reply. Don't call stop_thinking() — that drops the channel.
            let (_think, visible) = super::strip_think_blocks(&self.stream_buf);
            self.thinking = visible.trim().is_empty();
            self.blink_counter = self.blink_counter.wrapping_add(1);
            self.blink_on = self.blink_counter % 4 < 2;
        }

        /// The portion of the streaming buffer to show live, with reasoning hidden.
        fn visible_stream(&self) -> String {
            let (_think, visible) = super::strip_think_blocks(&self.stream_buf);
            visible
        }

        fn start_thinking(&mut self) {
            self.thinking = true;
            self.thinking_tick = 0;
        }

        fn stop_thinking(&mut self) {
            self.thinking = false;
            self.inference_rx = None;
            // Dropping a pending reply channel reads as a denial on the
            // inference side, so a cancelled turn can't leave a tool waiting.
            self.pending_approval = None;
        }

        fn tick_thinking(&mut self) {
            self.thinking_tick = self.thinking_tick.wrapping_add(1);
        }

        fn thinking_frame(&self) -> &'static str {
            let idx = (self.thinking_tick as usize) % THINKING_FRAMES.len();
            THINKING_FRAMES[idx]
        }

        fn tick(&mut self) {
            self.load_tick = self.load_tick.wrapping_add(1);
        }

        /// check how much of the model has landed on disk so far
        fn poll_download_progress(&mut self) {
            let Some(ref model_id) = self.switching_model_id else {
                return;
            };
            let cache_path = onde::hf_cache::model_cache_path(model_id);
            let downloaded = cache_path
                .as_ref()
                .filter(|p| p.exists())
                .map(|p| dir_size_recursive(p))
                .unwrap_or(0);
            let expected = onde::inference::models::SUPPORTED_MODEL_INFO
                .iter()
                .find(|m| m.id == model_id.as_str())
                .map(|m| m.expected_size_bytes)
                .unwrap_or(0);
            self.download_progress = Some((downloaded, expected));
        }

        /// switch to chat phase and show the welcome banner
        fn finish_loading(&mut self) {
            self.is_loading = false;
            for line in BANNER_ART.lines() {
                self.messages.push(ChatMessage::banner(line));
            }
            self.messages.push(ChatMessage::system(""));
            self.messages.push(ChatMessage::system(
                "In this world, nothing can be said to be certain, except death and taxes. ~ Pak Sigit",
            ));
            if self.backend.is_remote() {
                self.messages.push(ChatMessage::system(format!(
                    "Current model: {}",
                    self.current_model_name
                )));
            } else {
                // On-device models are never loaded implicitly; prompt the user to
                // load one explicitly before their first message.
                self.messages.push(ChatMessage::system(format!(
                    "No on-device model loaded. Run /load to load {}, or /models to choose one.",
                    self.current_model_name
                )));
            }
            self.messages
                .push(ChatMessage::system("Type /help for commands."));
        }

        /// store the error but stay in loading view so the user can read it
        fn set_load_error(&mut self, error: String) {
            self.load_error = Some(error);
            // is_loading stays true so render_loading() keeps rendering.
        }

        fn open_model_picker(&mut self, engine: &ChatEngine) {
            let current = crate::setup::load_selected_model();
            let current_name = crate::setup::load_selected_model_name().unwrap_or_else(|| {
                futures::executor::block_on(engine.info())
                    .model_name
                    .unwrap_or_else(|| self.current_model_name.clone())
            });

            self.model_picker_items = build_model_picker_items();
            self.model_picker_index = current
                .as_ref()
                .and_then(|selected| {
                    self.model_picker_items.iter().position(|item| {
                        item.config.model_id == selected.model_id
                            && item
                                .config
                                .files
                                .iter()
                                .any(|file| file == &selected.gguf_file)
                    })
                })
                .or_else(|| {
                    self.model_picker_items
                        .iter()
                        .position(|item| item.display_name == current_name)
                })
                .unwrap_or(0);
            self.show_model_picker = true;
        }

        fn close_model_picker(&mut self) {
            self.show_model_picker = false;
        }

        fn move_model_picker_up(&mut self) {
            if self.model_picker_items.is_empty() {
                return;
            }
            if self.model_picker_index == 0 {
                self.model_picker_index = self.model_picker_items.len().saturating_sub(1);
            } else {
                self.model_picker_index -= 1;
            }
        }

        fn move_model_picker_down(&mut self) {
            if self.model_picker_items.is_empty() {
                return;
            }
            self.model_picker_index = (self.model_picker_index + 1) % self.model_picker_items.len();
        }
    }

    // ── Model picker ─────────────────────────────────────────────────────────
    //
    // picker data types live in crate::models so Windows (ACP-only) can use them too

    fn render_model_picker(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let popup = centered_rect(82, 72, area);

        // clear the background so text doesn't bleed through
        frame.render_widget(Clear, popup);

        let block = Block::default()
            .title(" Select a model… ")
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray))
            .style(Style::default().bg(Color::Black));

        let inner = block.inner(popup);
        frame.render_widget(block, popup);

        let active_kind = crate::models::active_inference_kind();
        let mut lines = Vec::new();

        // State banner: which mode is active, and how to flip it.
        let (state_word, state_style) = match active_kind {
            InferenceKind::Local => (
                "ON  (on-device)",
                Style::default().fg(Color::Green).bg(Color::Black),
            ),
            InferenceKind::Cloud => (
                "OFF (siGit Code Cloud)",
                Style::default().fg(Color::Magenta).bg(Color::Black),
            ),
        };
        lines.push(Line::from(vec![
            Span::styled(
                "Local inference: ",
                Style::default()
                    .fg(Color::White)
                    .bg(Color::Black)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(state_word, state_style.add_modifier(Modifier::BOLD)),
            Span::styled(
                "    toggle with /local on|off",
                Style::default().fg(Color::DarkGray).bg(Color::Black),
            ),
        ]));
        lines.push(Line::from("").style(Style::default().bg(Color::Black)));

        let mut last_section: Option<ModelSource> = None;
        let mut last_kind: Option<InferenceKind> = None;

        for (index, item) in app.model_picker_items.iter().enumerate() {
            let item_kind = item.source.kind();
            let item_active = item_kind == active_kind;

            // Top-level group header (Local / Cloud) whenever the nature changes.
            if last_kind != Some(item_kind) {
                if last_kind.is_some() {
                    lines.push(Line::from("").style(Style::default().bg(Color::Black)));
                }
                let group_label = match item_kind {
                    InferenceKind::Local => "LOCAL — on-device inference",
                    InferenceKind::Cloud => "CLOUD — siGit Code Cloud",
                };
                let group_style = if item_active {
                    Style::default()
                        .fg(Color::White)
                        .bg(Color::Black)
                        .add_modifier(Modifier::BOLD | Modifier::UNDERLINED)
                } else {
                    Style::default().fg(Color::DarkGray).bg(Color::Black)
                };
                lines.push(
                    Line::from(vec![Span::styled(group_label, group_style)])
                        .style(Style::default().bg(Color::Black)),
                );
                last_kind = Some(item_kind);
                last_section = None;
            }

            if last_section != Some(item.source) {
                if last_section.is_some() {
                    lines.push(Line::from("").style(Style::default().bg(Color::Black)));
                }

                let (section_mark, section_name, section_style) = match item.source {
                    ModelSource::Onde => (
                        "◉",
                        "Onde Inference",
                        Style::default()
                            .fg(Color::Green)
                            .bg(Color::Black)
                            .add_modifier(Modifier::BOLD),
                    ),
                    ModelSource::HuggingFace => (
                        "○",
                        "Hugging Face cache",
                        Style::default()
                            .fg(Color::Cyan)
                            .bg(Color::Black)
                            .add_modifier(Modifier::BOLD),
                    ),
                    ModelSource::Available => (
                        "↓",
                        "Available for download",
                        Style::default()
                            .fg(Color::Blue)
                            .bg(Color::Black)
                            .add_modifier(Modifier::BOLD),
                    ),
                    ModelSource::Fallback => (
                        "◎",
                        "Fallback",
                        Style::default()
                            .fg(Color::Yellow)
                            .bg(Color::Black)
                            .add_modifier(Modifier::BOLD),
                    ),
                    ModelSource::Cloud => (
                        "☁",
                        "siGit Code Cloud",
                        Style::default()
                            .fg(Color::Magenta)
                            .bg(Color::Black)
                            .add_modifier(Modifier::BOLD),
                    ),
                };

                // Dim the section header when it belongs to the inactive group.
                let section_style = if item_active {
                    section_style
                } else {
                    Style::default().fg(Color::DarkGray).bg(Color::Black)
                };

                lines.push(
                    Line::from(vec![
                        Span::styled(format!("  {section_mark} "), section_style),
                        Span::styled(section_name, section_style),
                    ])
                    .style(Style::default().bg(Color::Black)),
                );
                last_section = Some(item.source);
            }

            let selected = index == app.model_picker_index;
            let current = item.display_name == app.current_model_name;
            let marker = if selected { "› " } else { "  " };
            let tool_badge = if item.tool_calling {
                "  ✓ tool calling"
            } else {
                ""
            };
            let health_badge = match item.cache_health {
                ModelCacheHealth::Complete => "",
                ModelCacheHealth::Incomplete => "  ! incomplete cache",
                ModelCacheHealth::NotDownloaded => "  ↓ download",
            };
            let current_badge = if current { "  ← current" } else { "" };
            let disabled_badge = match item.cache_health {
                ModelCacheHealth::Complete | ModelCacheHealth::NotDownloaded => "",
                ModelCacheHealth::Incomplete => "  (unselectable)",
            };
            let brand_mark = match item.source {
                ModelSource::Onde => "◉",
                ModelSource::HuggingFace => "○",
                ModelSource::Available => "↓",
                ModelSource::Fallback => "◎",
                ModelSource::Cloud => "☁",
            };
            let source = format!("  [{} {}]", brand_mark, item.source_label);

            let base_style = if selected {
                Style::default().fg(Color::Black).bg(Color::Green)
            } else if item_active {
                Style::default().fg(Color::White).bg(Color::Black)
            } else {
                // Inactive group: still visible (we surface the offering) but dimmed.
                Style::default().fg(Color::DarkGray).bg(Color::Black)
            };

            let source_style = if selected {
                Style::default().fg(Color::Black).bg(Color::Green)
            } else if !item_active {
                Style::default().fg(Color::DarkGray).bg(Color::Black)
            } else {
                match item.source {
                    ModelSource::Onde => Style::default().fg(Color::Green).bg(Color::Black),
                    ModelSource::HuggingFace => Style::default().fg(Color::Cyan).bg(Color::Black),
                    ModelSource::Available => Style::default().fg(Color::Blue).bg(Color::Black),
                    ModelSource::Fallback => Style::default().fg(Color::Yellow).bg(Color::Black),
                    ModelSource::Cloud => Style::default().fg(Color::Magenta).bg(Color::Black),
                }
            };

            let health_style = if selected {
                Style::default().fg(Color::Red).bg(Color::Green)
            } else {
                Style::default().fg(Color::Red).bg(Color::Black)
            };

            lines.push(Line::from(vec![
                Span::styled(
                    format!("{marker}{}  {}", item.display_name, item.description),
                    base_style,
                ),
                Span::styled(
                    tool_badge.to_string(),
                    if selected {
                        Style::default().fg(Color::Black).bg(Color::Green)
                    } else {
                        Style::default().fg(Color::Green).bg(Color::Black)
                    },
                ),
                Span::styled(health_badge.to_string(), health_style),
                Span::styled(
                    disabled_badge.to_string(),
                    if selected {
                        Style::default().fg(Color::Black).bg(Color::Green)
                    } else {
                        Style::default().fg(Color::DarkGray).bg(Color::Black)
                    },
                ),
                Span::styled(
                    current_badge.to_string(),
                    if selected {
                        Style::default().fg(Color::Black).bg(Color::Green)
                    } else {
                        Style::default().fg(Color::Cyan).bg(Color::Black)
                    },
                ),
                Span::styled(source, source_style),
            ]));
        }

        frame.render_widget(
            Paragraph::new(lines)
                .wrap(Wrap { trim: false })
                .style(Style::default().bg(Color::Black)),
            inner,
        );
    }

    fn centered_rect(
        percent_x: u16,
        percent_y: u16,
        area: ratatui::layout::Rect,
    ) -> ratatui::layout::Rect {
        let vertical = Layout::vertical([
            Constraint::Percentage((100 - percent_y) / 2),
            Constraint::Percentage(percent_y),
            Constraint::Percentage((100 - percent_y) / 2),
        ])
        .split(area);

        Layout::horizontal([
            Constraint::Percentage((100 - percent_x) / 2),
            Constraint::Percentage(percent_x),
            Constraint::Percentage((100 - percent_x) / 2),
        ])
        .split(vertical[1])[1]
    }

    // ── Tab bar (Session / History / Cloud) ───────────────────────────────────

    /// Switch to `tab`, refreshing the data it shows. Entering History rescans
    /// the sessions dir; entering Cloud kicks off the async status fetch.
    fn switch_tab(app: &mut App, tab: Tab, engine: &Arc<ChatEngine>) {
        app.active_tab = tab;
        match tab {
            Tab::Session => {}
            Tab::History => {
                app.refresh_history();
                app.history_notice = None;
            }
            Tab::Cloud => refresh_cloud(app, engine),
        }
    }

    /// Fetch the Cloud tab's status text on a background task and cache it.
    /// Account status and engine info are async (the account check may hit the
    /// network), so the tab shows "fetching…" until the oneshot resolves in
    /// the event loop.
    fn refresh_cloud(app: &mut App, engine: &Arc<ChatEngine>) {
        let (tx, rx) = oneshot::channel();
        app.cloud_rx = Some(rx);
        app.cloud_lines = None;

        let engine = Arc::clone(engine);
        let is_remote = app.backend.is_remote();
        let model_name = app.current_model_name.clone();
        tokio::spawn(async move {
            // `status_line` already folds failures into its message, so a dead
            // network degrades to an error string rather than a stuck tab.
            let account = crate::account::status_line().await;
            let info = engine.info().await;

            let mut lines = Vec::new();
            lines.push(format!("Account:          {account}"));
            lines.push(format!(
                "Inference:        {}",
                if is_remote {
                    "remote (siGit Code Cloud / hosted endpoint)"
                } else {
                    "on-device"
                }
            ));
            lines.push(format!("Model:            {model_name}"));
            lines.push(format!(
                "Engine:           status: {:?}  model: {}  memory: {}  history: {} turns",
                info.status,
                info.model_name.as_deref().unwrap_or("(none)"),
                info.approx_memory.as_deref().unwrap_or("unknown"),
                info.history_length,
            ));
            lines.push(format!(
                "Local inference:  {}",
                if crate::settings::local_inference_enabled() {
                    "on"
                } else {
                    "off"
                }
            ));
            let config_dir = std::env::var("SIGIT_CONFIG_DIR").unwrap_or_else(|_| {
                let home = std::env::var("HOME").unwrap_or_else(|_| "~".to_string());
                format!("{home}/.config/sigit")
            });
            lines.push(format!("Config dir:       {config_dir}"));
            lines.push(String::new());
            for perm_line in crate::permissions::describe(crate::permissions::TUI_SESSION).lines() {
                lines.push(perm_line.to_string());
            }

            let _ = tx.send(lines);
        });
    }

    /// Keys on the History and Cloud tabs (the Session tab keeps `handle_key`).
    /// Tab/Esc navigation is handled earlier in the event loop; this gets the
    /// rest.
    async fn handle_tab_key(app: &mut App, key: KeyEvent, engine: &Arc<ChatEngine>) {
        let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
        if ctrl && matches!(key.code, KeyCode::Char('c') | KeyCode::Char('d')) {
            app.quit = true;
            return;
        }

        match app.active_tab {
            Tab::Session => {}
            Tab::History => match key.code {
                KeyCode::Up => {
                    app.history_pending_delete = None;
                    app.history_index = app.history_index.saturating_sub(1);
                }
                KeyCode::Down => {
                    app.history_pending_delete = None;
                    if app.history_index + 1 < app.history_sessions.len() {
                        app.history_index += 1;
                    }
                }
                KeyCode::Char('r') => {
                    app.refresh_history();
                    app.history_notice = None;
                }
                KeyCode::Char('d') => {
                    let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
                        return;
                    };
                    if app.history_pending_delete.as_deref() == Some(id.as_str()) {
                        crate::session_store::delete(&id);
                        app.refresh_history();
                        app.history_notice = Some(format!("Deleted session '{id}'."));
                    } else {
                        app.history_pending_delete = Some(id);
                    }
                }
                KeyCode::Enter => {
                    app.history_pending_delete = None;
                    let Some(id) = app.selected_session().map(|e| e.id.clone()) else {
                        return;
                    };
                    match crate::session_store::load(&id) {
                        Some(history) if !history.is_empty() => {
                            let restored = history.len();
                            app.backend.restore_history(history).await;
                            app.messages.push(ChatMessage::system(format!(
                                "Restored {restored} message(s) from session '{id}'. \
                                 The model remembers the conversation; the scrollback \
                                 above does not replay it."
                            )));
                            app.active_tab = Tab::Session;
                        }
                        _ => {
                            app.history_notice = Some(format!(
                                "Could not restore '{id}': the session is empty or unreadable."
                            ));
                        }
                    }
                }
                // Any other key cancels a pending delete confirmation.
                _ => app.history_pending_delete = None,
            },
            Tab::Cloud => match key.code {
                KeyCode::Char('l') => {
                    let enabled = !crate::settings::local_inference_enabled();
                    match crate::settings::set_local_inference(enabled) {
                        Ok(()) => refresh_cloud(app, engine),
                        Err(error) => {
                            app.cloud_lines
                                .get_or_insert_with(Vec::new)
                                .push(format!("error: could not save the setting: {error}"));
                        }
                    }
                }
                KeyCode::Char('r') => refresh_cloud(app, engine),
                _ => {}
            },
        }
    }

    fn render_tab_bar(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let tabs = Tabs::new(Tab::TITLES.map(Line::from).to_vec())
            .select(app.active_tab.index())
            .style(Style::default().fg(Color::DarkGray))
            .highlight_style(
                Style::default()
                    .fg(Color::Black)
                    .bg(Color::Green)
                    .add_modifier(Modifier::BOLD),
            );
        frame.render_widget(tabs, area);
    }

    fn render_history_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray))
            .title(" saved sessions ");
        let inner = block.inner(area);
        frame.render_widget(block, area);

        let mut lines: Vec<Line> = Vec::new();

        if app.history_sessions.is_empty() {
            lines.push(Line::from(Span::styled(
                "  No saved sessions yet. Sessions are saved after each turn.",
                Style::default().fg(Color::DarkGray),
            )));
        } else {
            let now = std::time::SystemTime::now();
            for (index, entry) in app.history_sessions.iter().enumerate() {
                let selected = index == app.history_index;
                let marker = if selected { "› " } else { "  " };
                let age = now.duration_since(entry.modified).ok();
                let row = super::history_row(&entry.id, age, entry.message_count);
                let style = if selected {
                    Style::default().fg(Color::Black).bg(Color::Green)
                } else {
                    Style::default().fg(Color::White)
                };
                lines.push(Line::from(Span::styled(format!("{marker}{row}"), style)));
            }
        }

        if let Some(ref id) = app.history_pending_delete {
            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled(
                format!("  Delete '{id}'? Press d again to confirm — any other key cancels."),
                Style::default().fg(Color::Yellow),
            )));
        } else if let Some(ref notice) = app.history_notice {
            lines.push(Line::from(""));
            lines.push(Line::from(Span::styled(
                format!("  {notice}"),
                Style::default().fg(Color::Yellow),
            )));
        }

        // Keep the selection visible when the list outgrows the pane.
        let inner_height = inner.height as usize;
        let scroll = app
            .history_index
            .saturating_sub(inner_height.saturating_sub(1)) as u16;
        frame.render_widget(
            Paragraph::new(lines)
                .wrap(Wrap { trim: false })
                .scroll((scroll, 0)),
            inner,
        );
    }

    fn render_cloud_tab(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray))
            .title(" siGit Code Cloud ");
        let inner = block.inner(area);
        frame.render_widget(block, area);

        let mut lines: Vec<Line> = Vec::new();
        match app.cloud_lines {
            None => lines.push(Line::from(Span::styled(
                "  fetching status…",
                Style::default().fg(Color::DarkGray),
            ))),
            Some(ref cloud_lines) => {
                for text in cloud_lines {
                    lines.push(Line::from(Span::styled(
                        format!("  {text}"),
                        Style::default().fg(Color::White),
                    )));
                }
            }
        }
        lines.push(Line::from(""));
        lines.push(Line::from(Span::styled(
            "  Note: toggling Local Inference takes effect for the next model \
             selection (/models); the running backend is not swapped.",
            Style::default().fg(Color::DarkGray),
        )));

        frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner);
    }

    // ── Slash commands ────────────────────────────────────────────────────────

    enum SlashCommand {
        Help,
        Clear,
        Status,
        /// picker UI, or jump straight to model N
        Models(Option<usize>),
        /// toggle on-device inference mode. `Some(true/false)` sets it, `None` flips it.
        Local(Option<bool>),
        /// List discovered Agent Skills.
        Skills,
        /// List configured MCP servers and their tools.
        Mcp,
        /// explicitly load the selected (or default) on-device model
        Load,
        /// `/login <email> <password>` — the raw argument, parsed when executed.
        Login(Option<String>),
        Logout,
        Whoami,
        /// Toggle plan mode (research only; mutating tools are denied with a
        /// prompt to present a plan). `Some(true/false)` sets it, `None` flips it.
        Plan(Option<bool>),
        /// Show the effective permission policy for this session.
        Permissions,
        /// Expand or collapse model reasoning on rendered messages.
        /// `Some(true/false)` sets it, `None` flips it.
        Thinking(Option<bool>),
        /// Expand or collapse all tool-call entries in the transcript.
        /// `Some(true/false)` sets it, `None` flips it. TUI-only.
        Tools(Option<bool>),
        /// Summarize-and-shrink the conversation history on demand.
        Compact,
        /// Restore the saved TUI session from disk.
        Resume,
        /// Generate (or improve) the repo's AGENTS.md by running an agent
        /// turn with [`crate::instructions::INIT_PROMPT`] as the user text.
        Init,
        Exit,
        Unknown(String),
    }

    fn parse_slash(input: &str) -> Option<SlashCommand> {
        let trimmed = input.trim();
        if !trimmed.starts_with('/') {
            return None;
        }
        let mut parts = trimmed.splitn(2, char::is_whitespace);
        let cmd = parts.next().unwrap_or("");
        let arg = parts.next().map(|s| s.trim());
        Some(match cmd {
            "/help" => SlashCommand::Help,
            "/clear" => SlashCommand::Clear,
            "/status" => SlashCommand::Status,
            "/models" => SlashCommand::Models(arg.and_then(|s| s.parse::<usize>().ok())),
            "/local" => SlashCommand::Local(parse_on_off(arg)),
            "/skills" => SlashCommand::Skills,
            "/mcp" => SlashCommand::Mcp,
            "/load" => SlashCommand::Load,
            "/login" => SlashCommand::Login(arg.map(str::to_string)),
            "/logout" => SlashCommand::Logout,
            "/whoami" => SlashCommand::Whoami,
            "/plan" => SlashCommand::Plan(parse_on_off(arg)),
            "/permissions" => SlashCommand::Permissions,
            "/thinking" => SlashCommand::Thinking(parse_on_off(arg)),
            "/tools" => SlashCommand::Tools(parse_on_off(arg)),
            "/compact" => SlashCommand::Compact,
            "/resume" => SlashCommand::Resume,
            "/init" => SlashCommand::Init,
            "/exit" | "/quit" | "/q" => SlashCommand::Exit,
            other => SlashCommand::Unknown(other.to_string()),
        })
    }

    /// `on`/`off` (and synonyms) → `Some(bool)`; missing or unrecognized → `None`
    /// (meaning "toggle the current value").
    fn parse_on_off(arg: Option<&str>) -> Option<bool> {
        match arg.map(|s| s.trim().to_ascii_lowercase())?.as_str() {
            "on" | "true" | "1" | "yes" => Some(true),
            "off" | "false" | "0" | "no" => Some(false),
            _ => None,
        }
    }

    // ── Rendering ─────────────────────────────────────────────────────────────

    fn render(frame: &mut Frame, app: &mut App) {
        let area = frame.area();

        if app.is_loading {
            let zones = Layout::vertical([
                Constraint::Length(1),
                Constraint::Min(1),
                Constraint::Length(1),
            ])
            .split(area);
            render_loading_title(frame, app, zones[0]);
            render_loading(frame, app, zones[1]);
            render_loading_footer(frame, zones[2]);
            return;
        }

        match app.active_tab {
            Tab::Session => {
                let zones = Layout::vertical([
                    Constraint::Length(1),
                    Constraint::Length(1),
                    Constraint::Min(1),
                    Constraint::Length(3),
                    Constraint::Length(1),
                ])
                .split(area);

                render_tab_bar(frame, app, zones[0]);
                render_title(frame, app, zones[1]);
                render_messages(frame, app, zones[2]);
                render_input(frame, app, zones[3]);
                render_footer(frame, app, zones[4]);
            }
            // No input pane on the non-chat tabs: the Tab key always cycles.
            Tab::History | Tab::Cloud => {
                let zones = Layout::vertical([
                    Constraint::Length(1),
                    Constraint::Length(1),
                    Constraint::Min(1),
                    Constraint::Length(1),
                ])
                .split(area);

                render_tab_bar(frame, app, zones[0]);
                render_title(frame, app, zones[1]);
                if app.active_tab == Tab::History {
                    render_history_tab(frame, app, zones[2]);
                } else {
                    render_cloud_tab(frame, app, zones[2]);
                }
                render_footer(frame, app, zones[3]);
            }
        }

        if app.show_model_picker {
            render_model_picker(frame, app, area);
        }
    }

    fn render_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let model_label = format!(" siGit — {} ", app.current_model_name);
        let tool_label = if app.tool_calling {
            " [tools on] "
        } else {
            " [tools off] "
        };
        let line = Line::from(vec![
            Span::styled(
                model_label,
                Style::default()
                    .fg(Color::Black)
                    .bg(Color::Green)
                    .add_modifier(Modifier::BOLD),
            ),
            Span::styled(
                tool_label,
                Style::default().fg(Color::Black).bg(Color::DarkGray),
            ),
        ]);
        frame.render_widget(Paragraph::new(line), area);
    }

    fn render_loading_title(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        const SPINNER: &[&str] = &["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
        let spin = SPINNER[(app.load_tick as usize) % SPINNER.len()];
        let label = format!(" siGit {} loading {}… ", spin, app.load_model_name);
        let line = Line::from(Span::styled(
            label,
            Style::default()
                .fg(Color::Black)
                .bg(Color::Green)
                .add_modifier(Modifier::BOLD),
        ));
        frame.render_widget(Paragraph::new(line), area);
    }

    fn render_loading(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let elapsed = app.load_start.elapsed().as_secs();
        let elapsed_str = if elapsed < 60 {
            format!("{}s", elapsed)
        } else {
            format!("{}m {}s", elapsed / 60, elapsed % 60)
        };

        let content = if let Some(ref err) = app.load_error {
            format!(
                "\n\n  ✗ Failed to load model after {}.\n\n  {}\n\n  Press Ctrl+C to exit.",
                elapsed_str, err
            )
        } else {
            format!(
                "\n\n  Loading model, please wait… ({})\n\n  The model is being initialised. This may take a moment on first run.",
                elapsed_str
            )
        };

        let style = if app.load_error.is_some() {
            Style::default().fg(Color::Red)
        } else {
            Style::default().fg(Color::White)
        };

        frame.render_widget(
            Paragraph::new(content)
                .style(style)
                .wrap(Wrap { trim: false }),
            area,
        );
    }

    fn render_loading_footer(frame: &mut Frame, area: ratatui::layout::Rect) {
        let line = Line::from(vec![
            Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
            Span::styled(" quit", Style::default().fg(Color::DarkGray)),
        ]);
        frame.render_widget(Paragraph::new(line), area);
    }

    fn render_messages(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let inner_width = area.width.saturating_sub(2);
        let inner_height = area.height.saturating_sub(2);

        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray));

        let inner = block.inner(area);
        frame.render_widget(block, area);

        let mut lines: Vec<Line> = Vec::new();

        for msg in &app.messages {
            render_chat_message(
                &mut lines,
                msg,
                inner_width as usize,
                app.tools_expanded,
                app.show_thinking,
            );
        }

        let streamed_visible = app.visible_stream();
        if !streamed_visible.is_empty() {
            let fake = ChatMessage {
                role: Role::Assistant,
                text: streamed_visible,
                think_block: None,
                tool_detail: None,
                tool_result: None,
            };
            render_chat_message(
                &mut lines,
                &fake,
                inner_width as usize,
                app.tools_expanded,
                app.show_thinking,
            );
            if app.blink_on
                && let Some(last) = lines.last_mut()
            {
                last.spans
                    .push(Span::styled("▋", Style::default().fg(Color::Green)));
            }
        }

        if app.thinking {
            lines.push(Line::from(Span::styled(
                format!("  {} thinking…", app.thinking_frame()),
                Style::default().fg(Color::DarkGray),
            )));
            // While the stream buffer holds only reasoning, show its live tail
            // under the label so the user watches the model think.
            let tail_width = (inner_width as usize).saturating_sub(4).max(8);
            for tail_line in super::streaming_think_tail(&app.stream_buf, tail_width, 3) {
                lines.push(Line::from(Span::styled(
                    format!("    {tail_line}"),
                    Style::default()
                        .fg(Color::DarkGray)
                        .add_modifier(Modifier::DIM | Modifier::ITALIC),
                )));
            }
        } else if app.switching_model {
            // Once the weights have fully landed on disk, swap the spinner for a
            // checkmark so it's clear the download finished and we're now loading
            // the model into memory (which can still take a while).
            let download_complete = matches!(
                app.download_progress,
                Some((downloaded, expected)) if expected > 0 && downloaded >= expected
            );

            if download_complete {
                let size_str = app
                    .download_progress
                    .map(|(_, expected)| format!(" ({})", format_size_human(expected)))
                    .unwrap_or_default();
                lines.push(Line::from(vec![
                    Span::styled("  ✓ ", Style::default().fg(Color::Green)),
                    Span::styled(
                        format!("model downloaded{size_str} — loading into memory…"),
                        Style::default().fg(Color::DarkGray),
                    ),
                ]));
            } else {
                let progress_str = if let Some((downloaded, expected)) = app.download_progress {
                    if expected > 0 {
                        let pct = (downloaded as f64 / expected as f64 * 100.0).min(100.0) as u8;
                        let dl_str = format_size_human(downloaded.min(expected));
                        let ex_str = format_size_human(expected);
                        format!(" — {dl_str} / {ex_str} ({pct}%)")
                    } else if downloaded > 0 {
                        format!(" — {} downloaded", format_size_human(downloaded))
                    } else {
                        String::new()
                    }
                } else {
                    String::new()
                };
                lines.push(Line::from(Span::styled(
                    format!("  {} switching model{progress_str}…", app.switching_frame()),
                    Style::default().fg(Color::DarkGray),
                )));
            }
        }

        // Always pin to the bottom so the latest message stays visible. There is
        // no scrollback, so we just need the exact number of wrapped rows the
        // paragraph occupies at this width — `line_count` runs the same
        // WordWrapper as rendering, so it never diverges from what's drawn (an
        // estimate would, e.g. by forgetting the `<think>` box lines, and scroll
        // too little — the bug this fixes).
        let paragraph = Paragraph::new(lines).wrap(Wrap { trim: false });
        let total_lines = paragraph.line_count(inner_width) as u16;
        let scroll = total_lines.saturating_sub(inner_height);

        frame.render_widget(paragraph.scroll((scroll, 0)), inner);
    }

    fn render_chat_message(
        lines: &mut Vec<Line<'static>>,
        msg: &ChatMessage,
        _width: usize,
        tools_expanded: bool,
        show_thinking: bool,
    ) {
        match msg.role {
            Role::Tool => {
                let dim = Style::default()
                    .fg(Color::Rgb(132, 132, 145))
                    .add_modifier(Modifier::DIM);
                for entry_line in super::tool_entry_lines(
                    &msg.text,
                    msg.tool_detail.as_deref().unwrap_or(""),
                    msg.tool_result.as_deref(),
                    tools_expanded,
                ) {
                    lines.push(Line::from(Span::styled(format!("  {entry_line}"), dim)));
                }
            }
            Role::Banner => {
                let palette = [
                    Color::Red,
                    Color::Yellow,
                    Color::Green,
                    Color::Cyan,
                    Color::Blue,
                    Color::Magenta,
                ];
                let mut spans = Vec::new();
                for (i, ch) in msg.text.chars().enumerate() {
                    let color = palette[i % palette.len()];
                    spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
                }
                lines.push(Line::from(spans));
            }
            Role::System => {
                for text_line in msg.text.split('\n') {
                    let trimmed = text_line.trim();
                    let (prefix, body) = if trimmed.is_empty() {
                        ("", "")
                    } else {
                        ("  · ", trimmed)
                    };

                    lines.push(Line::from(vec![
                        Span::styled(
                            prefix.to_string(),
                            Style::default()
                                .fg(Color::Rgb(90, 90, 98))
                                .add_modifier(Modifier::DIM),
                        ),
                        Span::styled(
                            body.to_string(),
                            Style::default()
                                .fg(Color::Rgb(132, 132, 145))
                                .add_modifier(Modifier::ITALIC | Modifier::DIM),
                        ),
                    ]));
                }
            }
            Role::User => {
                let prefix = Span::styled(
                    "you > ".to_string(),
                    Style::default()
                        .fg(Color::Green)
                        .add_modifier(Modifier::BOLD),
                );
                let mut first = true;
                for text_line in msg.text.split('\n') {
                    if first {
                        lines.push(Line::from(vec![
                            prefix.clone(),
                            Span::raw(text_line.to_string()),
                        ]));
                        first = false;
                    } else {
                        lines.push(Line::from(Span::raw(format!("       {text_line}"))));
                    }
                }
            }
            Role::Assistant => {
                if let Some(ref think) = msg.think_block {
                    if show_thinking {
                        // /thinking on — the full reasoning, dim italic, with a
                        // blank line separating it from the answer below.
                        lines.push(Line::from(Span::styled(
                            "  · thinking".to_string(),
                            Style::default()
                                .fg(Color::DarkGray)
                                .add_modifier(Modifier::DIM),
                        )));
                        for think_line in think.split('\n') {
                            lines.push(Line::from(Span::styled(
                                format!("    {think_line}"),
                                Style::default()
                                    .fg(Color::DarkGray)
                                    .add_modifier(Modifier::DIM | Modifier::ITALIC),
                            )));
                        }
                        lines.push(Line::from(""));
                    } else {
                        // collapsed — one dim line noting reasoning exists
                        lines.push(Line::from(Span::styled(
                            format!("  {}", super::think_indicator(think)),
                            Style::default()
                                .fg(Color::DarkGray)
                                .add_modifier(Modifier::DIM),
                        )));
                    }
                }

                let prefix = Span::styled(
                    "siGit > ".to_string(),
                    Style::default()
                        .fg(Color::Cyan)
                        .add_modifier(Modifier::BOLD),
                );
                let body_style = Style::default();
                let bold_style = Style::default().add_modifier(Modifier::BOLD);
                let mut first = true;
                for text_line in msg.text.split('\n') {
                    if first {
                        let mut spans = vec![prefix.clone()];
                        spans.extend(rich_text_spans(text_line, body_style, bold_style));
                        lines.push(Line::from(spans));
                        first = false;
                    } else {
                        let mut spans = vec![Span::raw("         ".to_string())];
                        spans.extend(rich_text_spans(text_line, body_style, bold_style));
                        lines.push(Line::from(spans));
                    }
                }
            }
        }
    }

    fn render_input(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(Color::DarkGray))
            .title(" message ");

        let inner = block.inner(area);
        frame.render_widget(block, area);

        let display = app.input.clone();
        frame.render_widget(
            Paragraph::new(display.clone()).wrap(Wrap { trim: false }),
            inner,
        );

        let col = (app.cursor as u16) % inner.width;
        let row = (app.cursor as u16) / inner.width;
        frame.set_cursor_position(Position {
            x: inner.x + col,
            y: inner.y + row,
        });
    }

    fn render_footer(frame: &mut Frame, app: &App, area: ratatui::layout::Rect) {
        let key_style = Style::default().fg(Color::Black).bg(Color::Green);
        let label_style = Style::default().fg(Color::DarkGray);

        if app.active_tab != Tab::Session {
            let hints: &[(&str, &str)] = match app.active_tab {
                Tab::History => &[
                    (" ↑/↓ ", " select  "),
                    (" Enter ", " resume  "),
                    (" d ", " delete  "),
                    (" r ", " refresh  "),
                    (" Tab ", " next tab  "),
                    (" Esc ", " session"),
                ],
                _ => &[
                    (" l ", " toggle local inference  "),
                    (" r ", " refresh  "),
                    (" Tab ", " next tab  "),
                    (" Esc ", " session"),
                ],
            };
            let mut spans = Vec::new();
            for (key, label) in hints {
                spans.push(Span::styled(key.to_string(), key_style));
                spans.push(Span::styled(label.to_string(), label_style));
            }
            frame.render_widget(Paragraph::new(Line::from(spans)), area);
            return;
        }

        let mut spans = vec![
            Span::styled(" Enter ", key_style),
            Span::styled(" send  ", label_style),
            Span::styled(" Tab ", key_style),
            Span::styled(" tabs  ", label_style),
            Span::styled(
                " /help ",
                Style::default().fg(Color::Black).bg(Color::DarkGray),
            ),
            Span::styled(" commands  ", Style::default().fg(Color::DarkGray)),
            Span::styled(" Ctrl+C ", Style::default().fg(Color::Black).bg(Color::Red)),
            Span::styled(" quit", Style::default().fg(Color::DarkGray)),
        ];

        if let Some((tool, _)) = &app.pending_approval {
            spans.push(Span::styled(
                format!("  allow {tool}? [y]es · [a]lways · [n]o"),
                Style::default().fg(Color::Yellow),
            ));
        } else if app.thinking || app.switching_model || app.is_streaming() {
            spans.push(Span::styled(
                "  (busy — Ctrl+C to cancel)",
                Style::default().fg(Color::Yellow),
            ));
        }

        frame.render_widget(Paragraph::new(Line::from(spans)), area);
    }

    fn handle_key(app: &mut App, key: KeyEvent) -> Option<String> {
        if key.kind != KeyEventKind::Press {
            return None;
        }

        if app.show_model_picker {
            match key.code {
                KeyCode::Esc => {
                    app.close_model_picker();
                    return None;
                }
                KeyCode::Up => {
                    app.move_model_picker_up();
                    return None;
                }
                KeyCode::Down => {
                    app.move_model_picker_down();
                    return None;
                }
                KeyCode::Enter => {
                    return Some(format!("/models {}", app.model_picker_index + 1));
                }
                _ => return None,
            }
        }

        match key.code {
            KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                app.quit = true;
                None
            }
            KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                app.quit = true;
                None
            }
            KeyCode::Enter => {
                if app.input.trim().is_empty() {
                    return None;
                }
                let text = app.input.drain(..).collect::<String>();
                app.cursor = 0;
                Some(text)
            }
            KeyCode::Backspace => {
                if app.cursor > 0 {
                    app.cursor -= 1;
                    app.input.remove(app.cursor);
                }
                None
            }
            KeyCode::Delete => {
                if app.cursor < app.input.len() {
                    app.input.remove(app.cursor);
                }
                None
            }
            KeyCode::Left => {
                app.cursor = app.cursor.saturating_sub(1);
                None
            }
            KeyCode::Right => {
                if app.cursor < app.input.len() {
                    app.cursor += 1;
                }
                None
            }
            KeyCode::Home => {
                app.cursor = 0;
                None
            }
            KeyCode::End => {
                app.cursor = app.input.len();
                None
            }
            KeyCode::Char(ch) => {
                app.input.insert(app.cursor, ch);
                app.cursor += 1;
                None
            }
            _ => None,
        }
    }

    // ── Explicit on-device model loading ──────────────────────────────────────

    /// The local model `/load` should bring up: the persisted selection if it
    /// still resolves to a known model, otherwise the first on-device (non-cloud)
    /// entry in the picker.
    fn default_local_model_item(app: &App) -> Option<ModelPickerItem> {
        if let Some(selected) = crate::setup::load_selected_model()
            && let Some(item) = app.model_picker_items.iter().find(|item| {
                item.config.model_id == selected.model_id
                    && item
                        .config
                        .files
                        .iter()
                        .any(|file| file == &selected.gguf_file)
            })
        {
            return Some(item.clone());
        }
        app.model_picker_items
            .iter()
            .find(|item| item.cloud_tier.is_none())
            .cloned()
    }

    /// Load `model` on-device on a dedicated loader thread, routing inference to a
    /// fresh `LocalBackend` and driving the switch-progress UI. The caller is
    /// responsible for any cloud-tier handling; this path is on-device only.
    fn start_local_model_load<B: ratatui::backend::Backend>(
        app: &mut App,
        model: ModelPickerItem,
        engine: Arc<ChatEngine>,
        terminal: &mut ratatui::Terminal<B>,
    ) {
        if model.cache_health == ModelCacheHealth::Incomplete {
            app.messages.push(ChatMessage::system(format!(
                "error: {} has an incomplete local cache and cannot be selected yet.",
                model.display_name
            )));
            return;
        }

        // Loading an on-device model puts us in local inference mode.
        let _ = crate::settings::set_local_inference(true);

        // Route inference on-device; the loader thread below fills the engine the
        // LocalBackend reads from.
        app.backend = Arc::new(LocalBackend::new(Arc::clone(&engine)));

        let loading_msg = if model.cache_health == ModelCacheHealth::NotDownloaded {
            format!(
                "Downloading and loading {} ({})… this may take a few minutes.",
                model.display_name, model.description
            )
        } else {
            format!("Loading {}…", model.display_name)
        };

        app.messages.push(ChatMessage::system(loading_msg));
        terminal.draw(|frame| render(frame, app)).ok();

        let (tx, rx) = mpsc::channel(1);
        app.model_load_rx = Some(rx);
        app.switching_model = true;
        app.switching_model_id = Some(model.config.model_id.clone());
        // Only show download progress for models not yet cached.
        app.download_progress = if model.cache_health == ModelCacheHealth::NotDownloaded {
            Some((0, 0))
        } else {
            None
        };

        let sampling = SamplingConfig {
            max_tokens: Some(model.max_tokens),
            ..SamplingConfig::default()
        };

        // own thread + runtime so block_in_place doesn't starve the TUI loop.
        // Fold in project instruction files (AGENTS.md / CLAUDE.md) for the launch
        // directory so the on-device model gets the same always-on context the
        // cloud and ACP paths get.
        let system_prompt = {
            let base = crate::system_prompt_for_model(model.tool_calling).to_string();
            match std::env::current_dir()
                .ok()
                .and_then(|cwd| crate::instructions::load_project_instructions(&cwd))
            {
                Some(extra) => format!("{base}\n\n{extra}"),
                None => base,
            }
        };
        let engine_handle = Arc::clone(&engine);
        let tool_calling = model.tool_calling;
        std::thread::spawn(move || {
            let rt = tokio::runtime::Runtime::new().expect("failed to create model-loader runtime");
            let update = rt.block_on(async move {
                match engine_handle
                    .load_gguf_model(
                        model.config.clone(),
                        Some(system_prompt.to_string()),
                        Some(sampling),
                    )
                    .await
                {
                    Ok(_) => ModelLoadUpdate::Loaded(model.display_name.clone()),
                    Err(err) => ModelLoadUpdate::Error(err.to_string()),
                }
            });
            // capacity-1 channel, receiver alive while switching
            let _ = tx.blocking_send(update);
        });
        // applied on ModelLoadUpdate::Loaded
        app.pending_tool_calling = Some(tool_calling);
    }

    // ── Slash command execution ───────────────────────────────────────────────

    async fn exec_slash<B: ratatui::backend::Backend>(
        app: &mut App,
        cmd: SlashCommand,
        engine: Arc<ChatEngine>,
        terminal: &mut ratatui::Terminal<B>,
    ) {
        match cmd {
            SlashCommand::Help => {
                app.messages.push(ChatMessage::system(
                    "/help          — show this message\n\
                     /models        — open the model picker\n\
                     /models N      — switch to model N\n\
                     /local [on|off]— toggle on-device inference mode\n\
                     /skills        — list available Agent Skills\n\
                     /mcp           — list MCP servers and their tools\n\
                     /load          — load the selected on-device model\n\
                     /login E P     — sign in to siGit Code Cloud\n\
                     /logout        — sign out\n\
                     /whoami        — show the signed-in account\n\
                     /plan [on|off] — plan mode: research only, no edits or commands\n\
                     /permissions   — show the tool permission policy\n\
                     /thinking [on|off] — expand or collapse model reasoning\n\
                     /tools [on|off]— expand or collapse tool-call details\n\
                     /compact       — summarize and shrink conversation history\n\
                     /resume        — restore the saved session from disk\n\
                     /init          — generate or improve the repo's AGENTS.md\n\
                     /clear         — wipe conversation history\n\
                     /status        — show engine status\n\
                     /exit          — quit chat",
                ));
            }
            SlashCommand::Clear => {
                let cleared = engine.clear_history().await;
                app.messages.clear();
                crate::permissions::reset_session(crate::permissions::TUI_SESSION);
                // The saved session must not resurrect what the user just wiped.
                crate::session_store::delete(TUI_STORE_SESSION);
                app.messages.push(ChatMessage::system(format!(
                    "Cleared {cleared} turn(s). History is empty.",
                )));
            }
            SlashCommand::Compact => {
                let before = crate::backend::estimate_tokens(&app.backend.history_snapshot().await);
                match app
                    .backend
                    .compact_history(crate::backend::COMPACT_KEEP_LAST)
                    .await
                {
                    Ok(()) => {
                        let snapshot = app.backend.history_snapshot().await;
                        let after = crate::backend::estimate_tokens(&snapshot);
                        // Keep the saved session in step with the compacted state.
                        if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot)
                        {
                            log::warn!("session save after /compact failed: {error}");
                        }
                        app.messages.push(ChatMessage::system(format!(
                            "Compacted history: ~{before} → ~{after} tokens (estimated)."
                        )));
                    }
                    Err(error) => {
                        app.messages
                            .push(ChatMessage::system(format!("Compaction failed: {error}")));
                    }
                }
            }
            SlashCommand::Resume => match crate::session_store::load(TUI_STORE_SESSION) {
                Some(history) if !history.is_empty() => {
                    let restored = history.len();
                    app.backend.restore_history(history).await;
                    app.messages.push(ChatMessage::system(format!(
                        "Restored {restored} message(s) from the saved session. \
                         The model remembers the conversation; the scrollback above does not \
                         replay it."
                    )));
                }
                _ => {
                    app.messages.push(ChatMessage::system(
                        "No saved session to resume. Sessions are saved after each turn.",
                    ));
                }
            },
            SlashCommand::Plan(value) => {
                use crate::permissions::{self, TUI_SESSION};
                let enabled = value.unwrap_or_else(|| !permissions::plan_mode(TUI_SESSION));
                permissions::set_plan_mode(TUI_SESSION, enabled);
                app.messages.push(ChatMessage::system(if enabled {
                    "Plan mode ON — research with read-only tools only; edits and commands \
                     are blocked until /plan off."
                } else {
                    "Plan mode OFF — tools may execute again (subject to the permission \
                     policy)."
                }));
            }
            SlashCommand::Permissions => {
                app.messages
                    .push(ChatMessage::system(crate::permissions::describe(
                        crate::permissions::TUI_SESSION,
                    )));
            }
            SlashCommand::Thinking(value) => {
                app.show_thinking = value.unwrap_or(!app.show_thinking);
                app.messages.push(ChatMessage::system(if app.show_thinking {
                    "Thinking display ON — model reasoning is shown above each reply."
                } else {
                    "Thinking display OFF — replies show a one-line indicator; \
                     /thinking to expand."
                }));
            }
            SlashCommand::Tools(value) => {
                app.tools_expanded = value.unwrap_or(!app.tools_expanded);
                app.messages
                    .push(ChatMessage::system(if app.tools_expanded {
                        "Tool calls expanded — /tools off to collapse them."
                    } else {
                        "Tool calls collapsed — /tools on to expand them."
                    }));
            }
            SlashCommand::Status => {
                let info = engine.as_ref().info().await;
                let model = info.model_name.as_deref().unwrap_or("(none)");
                let mem = info.approx_memory.as_deref().unwrap_or("unknown");
                app.messages.push(ChatMessage::system(format!(
                    "status: {:?}  model: {}  memory: {}  history: {} turns",
                    info.status, model, mem, info.history_length,
                )));
            }
            SlashCommand::Skills => {
                app.messages
                    .push(ChatMessage::system(crate::skills::format_skills_list()));
            }
            SlashCommand::Mcp => {
                app.messages
                    .push(ChatMessage::system(crate::mcp::status_summary()));
            }
            SlashCommand::Models(selection) => match selection {
                None => {
                    app.open_model_picker(&engine);
                }
                Some(n) => {
                    let idx = n.saturating_sub(1);
                    match app.model_picker_items.get(idx).cloned() {
                        None => {
                            app.messages.push(ChatMessage::system(format!(
                                "error: no model #{n} — type /models to see the list."
                            )));
                        }
                        Some(model) => {
                            // ── siGit Code Cloud tier: no local load; sign-in gated ──
                            if let Some(tier) = model.cloud_tier.clone() {
                                app.close_model_picker();
                                match crate::provider::cloud_tier_provider(&tier) {
                                    Some(provider) => {
                                        let system_prompt =
                                            crate::system_prompt_for_model(true).to_string();
                                        app.backend = Arc::new(OpenAiBackend::new(
                                            provider.base_url,
                                            provider.api_key,
                                            provider.model,
                                            Some(system_prompt),
                                        ));
                                        app.current_model_name = provider.display_name.clone();
                                        app.tool_calling = true;
                                        // Selecting a cloud tier puts us in cloud mode.
                                        let _ = crate::settings::set_local_inference(false);
                                        app.messages.push(ChatMessage::system(format!(
                                            "Switched to {}.",
                                            provider.display_name
                                        )));
                                    }
                                    None => {
                                        app.messages.push(ChatMessage::system(
                                            "siGit Code Cloud needs an account. Use \
                                             `/login <email> <password>`, or create one at sigit.si.",
                                        ));
                                    }
                                }
                                return;
                            }

                            app.close_model_picker();
                            start_local_model_load(app, model, Arc::clone(&engine), terminal);
                        }
                    }
                }
            },
            SlashCommand::Local(value) => {
                let enabled = value.unwrap_or(!crate::settings::local_inference_enabled());
                match crate::settings::set_local_inference(enabled) {
                    Ok(()) => {
                        let state = if enabled { "on" } else { "off" };
                        let hint = if enabled {
                            "On-device models are highlighted. Type /models to pick one."
                        } else {
                            "siGit Code Cloud tiers are highlighted. Type /models to pick one."
                        };
                        app.messages.push(ChatMessage::system(format!(
                            "Local inference is {state}. {hint}"
                        )));
                        // Refresh the picker so emphasis/order reflects the new mode.
                        if app.show_model_picker {
                            app.open_model_picker(&engine);
                        }
                    }
                    Err(error) => {
                        app.messages.push(ChatMessage::system(format!(
                            "error: could not save local inference setting: {error}"
                        )));
                    }
                }
            }
            SlashCommand::Load => match default_local_model_item(app) {
                None => {
                    app.messages.push(ChatMessage::system(
                        "No local model available to load. Use /models to see the list.",
                    ));
                }
                Some(model) => {
                    start_local_model_load(app, model, Arc::clone(&engine), terminal);
                }
            },
            SlashCommand::Login(arg) => {
                let message = match arg.as_deref().and_then(crate::account::parse_login_args) {
                    Some((email, password)) => {
                        match crate::account::authenticate(&email, &password).await {
                            Ok(email) => format!(
                                "Signed in as {email}. siGit Code Cloud applies to your next session."
                            ),
                            Err(error) => format!("Login failed: {error}"),
                        }
                    }
                    None => "usage: /login <email> <password>".to_string(),
                };
                app.messages.push(ChatMessage::system(message));
            }
            SlashCommand::Logout => {
                let message = crate::account::end_session().await;
                app.messages.push(ChatMessage::system(message));
            }
            SlashCommand::Whoami => {
                let message = crate::account::status_line().await;
                app.messages.push(ChatMessage::system(message));
            }
            SlashCommand::Init => {
                // Handled in the input loop (it runs a real turn); reaching
                // this arm means a caller forgot that special case.
                app.messages
                    .push(ChatMessage::system("Type /init in the chat to run it."));
            }
            SlashCommand::Exit => {
                app.quit = true;
            }
            SlashCommand::Unknown(cmd) => {
                app.messages
                    .push(ChatMessage::system(format!("unknown command: {cmd}")));
            }
        }
    }

    // ── Background inference task ─────────────────────────────────────────────

    /// cap tool rounds so a confused model can't loop forever; auto-compaction
    /// keeps long runs inside the context window, so the cap can be generous
    const MAX_TOOL_ROUNDS: usize = 24;

    /// The TUI is a single conversation, so it persists under one fixed
    /// session-store id (ACP sessions use their protocol-assigned ids).
    const TUI_STORE_SESSION: &str = "tui";

    fn build_tool_specs() -> Vec<ToolSpec> {
        let mut specs: Vec<ToolSpec> = crate::tools::all_tools()
            .into_iter()
            .map(|t| ToolSpec {
                name: t.name.to_string(),
                description: t.description.to_string(),
                parameters_schema: t.parameters_schema.to_string(),
            })
            .collect();

        // Advertise the Agent Skills `skill` tool only when skills exist on disk
        // (https://agentskills.io). The tool description carries the discovery
        // list (name + description) for progressive disclosure.
        let discovered = crate::skills::discover_skills();
        if !discovered.is_empty() {
            specs.push(ToolSpec {
                name: crate::skills::SKILL_TOOL_NAME.to_string(),
                description: crate::skills::skill_tool_description(&discovered),
                parameters_schema: crate::skills::skill_tool_schema().to_string(),
            });
        }

        // Delegated research (`task`) is offered only when a subagent backend
        // can actually be built — same conditional pattern as `skill` above.
        if crate::tools::subagent_available() {
            specs.push(crate::tools::task_tool_spec());
        }

        // Tools discovered from configured MCP servers (incl. the official one).
        specs.extend(crate::mcp::tool_specs());

        specs
    }

    /// Close out a cancelled round in backend history: the results of tools
    /// that already ran this round, plus cancellation notes for `unreached`
    /// calls. Leaving a round's tool calls unanswered breaks strict
    /// OpenAI-compatible endpoints on the session's next request.
    async fn abandon_round(
        backend: &dyn InferenceBackend,
        mut tool_results: Vec<ToolResult>,
        unreached: &[crate::backend::ToolCall],
    ) {
        for pending in unreached {
            tool_results.push(ToolResult {
                tool_call_id: pending.id.clone(),
                content: format!(
                    "`{}` was not executed: the user cancelled the turn.",
                    pending.name
                ),
            });
        }
        backend.record_cancelled_tool_results(tool_results).await;
    }

    /// run the tool-calling loop off the main thread, posting updates via `tx`.
    /// dropping `tx` signals completion to the event loop.
    async fn run_inference_task(
        backend: Arc<dyn InferenceBackend>,
        text: String,
        tx: mpsc::Sender<InferenceUpdate>,
        tools_enabled: bool,
    ) {
        let tools = if tools_enabled {
            build_tool_specs()
        } else {
            vec![]
        };

        // Bridge the backend's token sink (plain strings) onto the UI update
        // channel as `Delta` messages. The forwarder lives for the whole turn.
        let (delta_tx, mut delta_rx) = mpsc::unbounded_channel::<String>();
        let forward_tx = tx.clone();
        let forwarder = tokio::spawn(async move {
            while let Some(piece) = delta_rx.recv().await {
                if forward_tx
                    .send(InferenceUpdate::Delta(piece))
                    .await
                    .is_err()
                {
                    break;
                }
            }
        });

        // The first round offers tools, so on-device inference can't stream it
        // (it must buffer to detect tool calls). With tools disabled there are
        // none to offer, so it streams directly.
        let first_sink = if tools.is_empty() {
            Some(&delta_tx)
        } else {
            None
        };
        let mut streamed = first_sink.is_some();

        let mut result = match backend
            .send_message_with_tools(&text, &tools, first_sink)
            .await
        {
            Ok(r) => r,
            Err(err) => {
                let _ = tx.send(InferenceUpdate::Error(err)).await;
                return;
            }
        };

        let mut round = 0;

        while !result.tool_calls.is_empty() && round < MAX_TOOL_ROUNDS {
            // any tool call means the first round didn't produce a final answer
            streamed = false;
            round += 1;
            log::info!("tool round {} — {} call(s)", round, result.tool_calls.len());

            // Auto-compaction: long tool runs grow history fast; fold it into
            // a summary before the next round rather than blowing the window.
            let estimate = crate::backend::estimate_tokens(&backend.history_snapshot().await);
            if estimate > crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET {
                log::info!(
                    "history ≈{estimate} tokens exceeds budget {} — compacting",
                    crate::backend::DEFAULT_CONTEXT_TOKEN_BUDGET
                );
                match backend
                    .compact_history(crate::backend::COMPACT_KEEP_LAST)
                    .await
                {
                    Ok(()) => {
                        let after =
                            crate::backend::estimate_tokens(&backend.history_snapshot().await);
                        log::info!("compacted history to ≈{after} tokens");
                    }
                    Err(error) => log::warn!("history compaction failed: {error}"),
                }
            }

            let mut tool_results = Vec::new();

            for (call_index, tc) in result.tool_calls.iter().enumerate() {
                // The UI drops the receiver on Ctrl+C or quit. Stop the turn
                // at the next boundary instead of burning model rounds (and
                // possibly running granted tools) in the background.
                if tx.is_closed() {
                    log::info!("turn cancelled by the user — stopping the tool loop");
                    abandon_round(&*backend, tool_results, &result.tool_calls[call_index..]).await;
                    return;
                }

                log::info!(
                    "  → {}({})",
                    tc.name,
                    tc.arguments.chars().take(120).collect::<String>()
                );

                let _ = tx
                    .send(InferenceUpdate::ToolUse {
                        name: tc.name.clone(),
                        arguments: tc.arguments.clone(),
                    })
                    .await;

                // Permission gate: read-only tools pass straight through; a
                // mutating tool consults policy and may pause on the user's
                // y/a/n answer (delivered over a oneshot from the event loop).
                use crate::permissions::{self, Decision, TUI_SESSION};
                let output = match permissions::decision_for(TUI_SESSION, &tc.name, &tc.arguments) {
                    Decision::Allow => crate::tools::execute_tool(&tc.name, &tc.arguments).await,
                    Decision::Deny(reason) => {
                        log::info!("  ✗ {} denied by policy", tc.name);
                        reason
                    }
                    Decision::Ask => {
                        let (reply_tx, reply_rx) = oneshot::channel();
                        let _ = tx
                            .send(InferenceUpdate::ApprovalRequest {
                                tool: tc.name.clone(),
                                args: permissions::approval_preview(&tc.arguments),
                                reply: reply_tx,
                            })
                            .await;
                        match reply_rx.await {
                            Ok(ApprovalChoice::Once) => {
                                crate::tools::execute_tool(&tc.name, &tc.arguments).await
                            }
                            Ok(ApprovalChoice::Session) => {
                                permissions::grant_for_session(
                                    TUI_SESSION,
                                    &tc.name,
                                    &tc.arguments,
                                );
                                crate::tools::execute_tool(&tc.name, &tc.arguments).await
                            }
                            Ok(ApprovalChoice::Deny) => {
                                log::info!("  ✗ {} denied by user", tc.name);
                                permissions::user_denial(&tc.name)
                            }
                            // The UI dropped the reply channel (Ctrl+C or
                            // quit): the whole turn is over, not just this
                            // call. Close out the round and stop instead of
                            // continuing rounds in the background.
                            Err(_) => {
                                log::info!(
                                    "turn cancelled at the approval prompt — stopping the tool loop"
                                );
                                abandon_round(
                                    &*backend,
                                    tool_results,
                                    &result.tool_calls[call_index..],
                                )
                                .await;
                                return;
                            }
                        }
                    }
                };
                log::info!("  ← {} chars", output.len());

                // Display-only preview of the output tail; the model gets the
                // full output through `tool_results` below.
                let _ = tx
                    .send(InferenceUpdate::ToolDone {
                        output_preview: super::cap_output_preview(&output),
                    })
                    .await;

                tool_results.push(ToolResult {
                    tool_call_id: tc.id.clone(),
                    content: output,
                });
            }

            // Cancelled while the round's tools ran: record what executed and
            // stop before paying for another model round nobody will see.
            if tx.is_closed() {
                log::info!("turn cancelled by the user — skipping the next model round");
                abandon_round(&*backend, tool_results, &[]).await;
                return;
            }

            // on the last round, pass no tools so the model must produce text —
            // that's also the round we can stream on-device.
            let next_tools = if round < MAX_TOOL_ROUNDS {
                Some(tools.as_slice())
            } else {
                None
            };
            let sink = if next_tools.is_none() {
                streamed = true;
                Some(&delta_tx)
            } else {
                None
            };

            match backend
                .send_tool_results(tool_results, next_tools, sink)
                .await
            {
                Ok(r) => result = r,
                Err(err) => {
                    let _ = tx.send(InferenceUpdate::Error(err)).await;
                    return;
                }
            }
        }

        // Drop the sink so the forwarder finishes draining any buffered tokens
        // before we commit the reply.
        drop(delta_tx);
        let _ = forwarder.await;

        if result.tool_calls.is_empty() {
            if result.text.is_empty() {
                log::warn!(
                    "model returned empty reply — may have exhausted max_tokens on thinking"
                );
                let _ = tx
                    .send(InferenceUpdate::Error(
                        "(empty response — the model may have used all tokens on internal reasoning. \
                         Try a shorter or simpler prompt.)"
                            .to_string(),
                    ))
                    .await;
            } else if streamed {
                // tokens already went out as deltas; just commit the buffer
                let _ = tx.send(InferenceUpdate::StreamEnd).await;
            } else {
                let _ = tx.send(InferenceUpdate::Response(result.text)).await;
            }
        }

        // Persist the completed turn so /resume (or a restart) can pick the
        // conversation back up.
        let snapshot = backend.history_snapshot().await;
        if let Err(error) = crate::session_store::save(TUI_STORE_SESSION, &snapshot) {
            log::warn!("session save failed: {error}");
        }

        log::info!("inference complete — {} tool round(s)", round);
        // tx drops here — event loop gets None from rx.recv()
    }

    // ── Main loop ─────────────────────────────────────────────────────────────

    /// entry point — blocks until the user quits.
    /// caller owns terminal init/restore. `load_rx` delivers the model-load result
    /// from a dedicated OS thread; we poll it non-blocking each tick.
    pub async fn run_with<B: ratatui::backend::Backend>(
        terminal: &mut ratatui::Terminal<B>,
        engine: Arc<ChatEngine>,
        backend: Arc<dyn InferenceBackend>,
        load_rx: std_mpsc::Receiver<Result<(), String>>,
        load_model_name: String,
    ) -> Result<()> {
        event_loop(terminal, engine, backend, load_rx, load_model_name).await
    }

    async fn event_loop<B: ratatui::backend::Backend>(
        terminal: &mut ratatui::Terminal<B>,
        engine: Arc<ChatEngine>,
        backend: Arc<dyn InferenceBackend>,
        load_rx: std_mpsc::Receiver<Result<(), String>>,
        load_model_name: String,
    ) -> Result<()> {
        let mut app = App::new(load_model_name, backend);
        let mut event_stream = EventStream::new();

        // 10 fps is plenty for spinners
        let mut ticker = interval(Duration::from_millis(100));

        loop {
            // ── Poll the loader channel (non-blocking) ────────────────────────
            if app.is_loading {
                match load_rx.try_recv() {
                    Ok(Ok(())) => app.finish_loading(),
                    Ok(Err(e)) => app.set_load_error(e),
                    Err(std_mpsc::TryRecvError::Empty) => {}
                    Err(std_mpsc::TryRecvError::Disconnected) => {
                        app.set_load_error("Model loader thread crashed.".to_string());
                    }
                }
            }

            // redraw every iteration
            terminal.draw(|frame| render(frame, &mut app))?;

            if let Some(rx) = app.model_load_rx.as_mut() {
                match rx.try_recv() {
                    Ok(ModelLoadUpdate::Loaded(model_name)) => {
                        engine.clear_history().await;
                        if let Some(tc) = app.pending_tool_calling.take() {
                            app.tool_calling = tc;
                        }
                        app.switching_model = false;
                        app.switching_model_id = None;
                        app.download_progress = None;
                        app.model_load_cancelled = false;
                        app.model_load_rx = None;
                        app.current_model_name = model_name.clone();

                        let save_result = app
                            .model_picker_items
                            .iter()
                            .find(|item| item.display_name == model_name)
                            .map(|item| crate::setup::SelectedModel {
                                model_id: item.config.model_id.clone(),
                                gguf_file: item
                                    .config
                                    .files
                                    .first()
                                    .cloned()
                                    .unwrap_or_else(String::new),
                            })
                            .filter(|selected| !selected.gguf_file.is_empty())
                            .map(|selected| crate::setup::save_selected_model(&selected))
                            .unwrap_or_else(|| {
                                Err(format!(
                                    "could not determine a stable identifier for {}",
                                    model_name
                                ))
                            });

                        if let Err(error) = save_result {
                            app.messages.push(ChatMessage::system(format!(
                                "warning: switched to {} but could not save the selection: {}",
                                model_name, error
                            )));
                        } else {
                            app.messages
                                .push(ChatMessage::system(format!("✓ Switched to {}", model_name)));
                        }
                    }
                    Ok(ModelLoadUpdate::Error(error)) => {
                        app.switching_model = false;
                        app.switching_model_id = None;
                        app.download_progress = None;
                        app.model_load_cancelled = false;
                        app.model_load_rx = None;
                        app.messages
                            .push(ChatMessage::system(format!("error loading model: {error}")));
                    }
                    Err(tokio::sync::mpsc::error::TryRecvError::Empty) => {}
                    Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
                        let was_cancelled = app.model_load_cancelled;
                        app.switching_model = false;
                        app.switching_model_id = None;
                        app.download_progress = None;
                        app.model_load_cancelled = false;
                        app.model_load_rx = None;
                        if !was_cancelled {
                            app.messages.push(ChatMessage::system(
                                "error loading model: loader task disconnected".to_string(),
                            ));
                        }
                    }
                }
            }

            if app.quit {
                break;
            }

            // multiplex terminal events, streaming tokens, inference updates,
            // and the thinking-spinner timer.
            tokio::select! {
                biased;

                // ── Spinner tick (loading phase only) ─────────────────────────
                _ = ticker.tick(), if app.is_loading => {
                    app.tick();
                }

                // ── inference updates from background task ───────────────────
                update = async {
                    match app.inference_rx.as_mut() {
                        Some(rx) => rx.recv().await,
                        None => pending().await,
                    }
                } => {
                    match update {
                        Some(InferenceUpdate::ToolUse { name, arguments }) => {
                            let title = super::tool_title(&name, &arguments);
                            let detail = super::pretty_tool_arguments(&arguments);
                            app.messages.push(ChatMessage::tool_call(title, detail));
                        }
                        Some(InferenceUpdate::ToolDone { output_preview }) => {
                            // Attach to the most recent tool entry still
                            // waiting for its result.
                            if let Some(entry) = app
                                .messages
                                .iter_mut()
                                .rev()
                                .find(|m| m.role == Role::Tool && m.tool_result.is_none())
                            {
                                entry.tool_result = Some(output_preview);
                            }
                        }
                        Some(InferenceUpdate::Delta(delta)) => {
                            app.push_stream_delta(&delta);
                        }
                        Some(InferenceUpdate::StreamEnd) => {
                            app.finalize_stream();
                        }
                        Some(InferenceUpdate::Response(text)) => {
                            app.stop_thinking();
                            app.messages.push(ChatMessage::assistant(text));
                        }
                        Some(InferenceUpdate::Error(msg)) => {
                            app.finalize_stream();
                            app.stop_thinking();
                            app.messages.push(ChatMessage::system(format!("error: {msg}")));
                        }
                        Some(InferenceUpdate::ApprovalRequest { tool, args, reply }) => {
                            // The y/a/n prompt lives on the Session tab; make
                            // sure the user can see what they're answering.
                            app.active_tab = Tab::Session;
                            let call = if args.is_empty() {
                                tool.clone()
                            } else {
                                format!("{tool}({args})")
                            };
                            app.messages.push(ChatMessage::system(format!(
                                "⚠ permission — allow {call}?  [y]es · [a]lways this session · [n]o"
                            )));
                            app.pending_approval = Some((tool, reply));
                        }
                        None => {
                            // task finished, possibly with no text to show
                            app.finalize_stream();
                            app.stop_thinking();
                        }
                    }
                }

                // ── Cloud tab status fetch resolving ─────────────────────────
                status = async {
                    match app.cloud_rx.as_mut() {
                        Some(rx) => rx.await,
                        None => pending().await,
                    }
                } => {
                    app.cloud_rx = None;
                    app.cloud_lines = Some(status.unwrap_or_else(|_| {
                        vec!["error: the status fetch task died — press r to retry".to_string()]
                    }));
                }

                // ── thinking / switching spinner tick (100ms) ────────────────
                _ = async {
                    if app.thinking || app.switching_model {
                        tokio::time::sleep(Duration::from_millis(100)).await
                    } else {
                        pending().await
                    }
                } => {
                    app.tick_thinking();
                    // keep the progress display fresh
                    if app.switching_model {
                        app.poll_download_progress();
                    }
                }

                // ── Terminal events ───────────────────────────────────────────
                maybe_event = event_stream.next() => {
                    let Some(Ok(event)) = maybe_event else {
                        break;
                    };

                    if let Event::Key(key) = event {
                        // loading phase — only quit keys work
                        if app.is_loading {
                            if key.kind == KeyEventKind::Press {
                                let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
                                if ctrl
                                    && (key.code == KeyCode::Char('c')
                                        || key.code == KeyCode::Char('d'))
                                {
                                    app.quit = true;
                                }
                            }
                            continue;
                        }

                        // pending tool approval — y/a/n answer the prompt; the
                        // inference task is paused on the reply channel. Checked
                        // before the busy gate because the app *is* busy here.
                        if app.pending_approval.is_some() {
                            if key.kind == KeyEventKind::Press {
                                let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
                                let choice = if ctrl
                                    && (key.code == KeyCode::Char('c')
                                        || key.code == KeyCode::Char('d'))
                                {
                                    // cancel the whole turn: denying is implicit
                                    // in dropping the reply channel
                                    app.pending_approval = None;
                                    app.stop_thinking();
                                    app.messages.push(ChatMessage::system("(cancelled)"));
                                    continue;
                                } else {
                                    match key.code {
                                        KeyCode::Char('y') | KeyCode::Char('Y') => {
                                            Some(ApprovalChoice::Once)
                                        }
                                        KeyCode::Char('a') | KeyCode::Char('A') => {
                                            Some(ApprovalChoice::Session)
                                        }
                                        KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Esc => {
                                            Some(ApprovalChoice::Deny)
                                        }
                                        _ => None,
                                    }
                                };
                                if let Some(choice) = choice
                                    && let Some((tool, reply)) = app.pending_approval.take()
                                {
                                    let verdict = match &choice {
                                        ApprovalChoice::Once => "allowed once",
                                        ApprovalChoice::Session => "allowed for this session",
                                        ApprovalChoice::Deny => "denied",
                                    };
                                    app.messages.push(ChatMessage::system(format!(
                                        "{tool}: {verdict}"
                                    )));
                                    let _ = reply.send(choice);
                                }
                            }
                            continue;
                        }

                        // ── Tab-bar navigation ────────────────────────────────
                        // Handled before the busy gate so the user can look at
                        // History/Cloud while inference runs (updates keep
                        // landing in the Session tab's message list). The Tab
                        // key only cycles when the input buffer is empty, so
                        // pasted text containing tabs can't fight it; on
                        // non-Session tabs the input is hidden, so it always
                        // cycles there.
                        if key.kind == KeyEventKind::Press && !app.show_model_picker {
                            if key.code == KeyCode::Tab
                                && (app.active_tab != Tab::Session || app.input.is_empty())
                            {
                                let next = app.active_tab.next();
                                switch_tab(&mut app, next, &engine);
                                continue;
                            }
                            if app.active_tab != Tab::Session && key.code == KeyCode::Esc {
                                app.active_tab = Tab::Session;
                                continue;
                            }
                        }

                        // busy — only cancel keys work
                        if app.is_busy() {
                            if key.kind == KeyEventKind::Press {
                                let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
                                if ctrl && (key.code == KeyCode::Char('c') || key.code == KeyCode::Char('d')) {
                                    if app.is_streaming() {
                                        app.finalize_stream();
                                        app.messages.push(ChatMessage::system("(cancelled)"));
                                    }
                                    if app.thinking {
                                        // dropping rx kills the background task
                                        app.stop_thinking();
                                        app.messages.push(ChatMessage::system("(cancelled)"));
                                    }
                                    if app.switching_model {
                                        // flag before drop so Disconnected handler stays quiet
                                        app.model_load_cancelled = true;
                                        app.switching_model = false;
                                        app.switching_model_id = None;
                                        app.download_progress = None;
                                        app.model_load_rx = None;
                                        app.messages
                                            .push(ChatMessage::system("(download cancelled — model switch aborted)"));
                                    }
                                }
                            }
                            continue;
                        }

                        // History / Cloud tabs have their own key handling; the
                        // chat input is inactive there.
                        if app.active_tab != Tab::Session {
                            if key.kind == KeyEventKind::Press {
                                handle_tab_key(&mut app, key, &engine).await;
                            }
                            continue;
                        }

                        if let Some(text) = handle_key(&mut app, key) {
                            // `/init` runs a real agent turn: the transcript
                            // shows the command the user typed, but the model
                            // receives the canned AGENTS.md-generation prompt.
                            let mut inference_text = text.clone();
                            match parse_slash(&text) {
                                Some(SlashCommand::Init) => {
                                    inference_text =
                                        crate::instructions::INIT_PROMPT.to_string();
                                }
                                Some(cmd) => {
                                    exec_slash(&mut app, cmd, Arc::clone(&engine), terminal)
                                        .await;
                                    continue;
                                }
                                None => {}
                            }

                            // On-device inference needs a model in memory, and we
                            // never load one implicitly: the user loads it with
                            // /load (or /models). Refuse rather than erroring out
                            // deep in the backend.
                            if !app.backend.is_remote()
                                && engine.info().await.status == onde::inference::EngineStatus::Unloaded
                            {
                                app.messages.push(ChatMessage::user(&text));
                                app.messages.push(ChatMessage::system(
                                    "No on-device model is loaded. Run /load to load the selected \
                                     model, or /models to choose one.",
                                ));
                                continue;
                            }

                            // ── spawn inference ──────────────────────────────
                            app.messages.push(ChatMessage::user(&text));
                            app.start_thinking();

                            let (tx, rx) = mpsc::channel::<InferenceUpdate>(64);
                            app.inference_rx = Some(rx);

                            let backend_handle = Arc::clone(&app.backend);
                            let user_text = inference_text;
                            let tools_enabled = app.tool_calling;
                            tokio::spawn(async move {
                                run_inference_task(backend_handle, user_text, tx, tools_enabled).await;
                            });
                        }
                    }
                }
            }
        }

        Ok(())
    }

    // ── Download progress helpers (TUI) ──────────────────────────────────────

    /// total bytes under `path`, following symlinks (hf-hub uses blobs + symlinks)
    fn dir_size_recursive(path: &std::path::Path) -> u64 {
        let mut total: u64 = 0;
        let Ok(entries) = std::fs::read_dir(path) else {
            return 0;
        };
        for entry in entries.flatten() {
            let entry_path = entry.path();
            if entry_path.is_dir() {
                total += dir_size_recursive(&entry_path);
            } else if let Ok(meta) = entry_path.metadata() {
                total += meta.len();
            }
        }
        total
    }

    fn format_size_human(bytes: u64) -> String {
        const GB: u64 = 1_073_741_824;
        const MB: u64 = 1_048_576;
        const KB: u64 = 1_024;
        if bytes >= GB {
            format!("{:.2} GB", bytes as f64 / GB as f64)
        } else if bytes >= MB {
            format!("{:.1} MB", bytes as f64 / MB as f64)
        } else if bytes >= KB {
            format!("{:.0} KB", bytes as f64 / KB as f64)
        } else {
            format!("{bytes} B")
        }
    }
} // end #[cfg(unix)] mod tui

// re-export so callers write `chat::run_with(...)` on all platforms
#[cfg(unix)]
pub use tui::run_with;

// ── Tests (platform-agnostic) ─────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use std::time::Duration;

    use super::{
        TOOL_RESULT_PREVIEW_MAX, Tab, cap_output_preview, format_age, history_row,
        parse_rich_text_segments, pretty_tool_arguments, streaming_think_tail, strip_think_blocks,
        think_indicator, tool_entry_lines, tool_title,
    };

    // ── tool_title ────────────────────────────────────────────────────────────

    #[test]
    fn tool_title_run_command_shows_the_command() {
        assert_eq!(
            tool_title("run_command", r#"{"command":"cargo test"}"#),
            "run_command · cargo test"
        );
    }

    #[test]
    fn tool_title_file_tools_show_the_path() {
        for name in [
            "read_file",
            "create_file",
            "edit_file",
            "multi_edit",
            "delete_file",
        ] {
            assert_eq!(
                tool_title(name, r#"{"path":"src/main.rs","old_text":"a"}"#),
                format!("{name} · src/main.rs")
            );
        }
    }

    #[test]
    fn tool_title_pattern_tools_show_the_pattern() {
        assert_eq!(
            tool_title("search_files", r#"{"pattern":"fn main","path":"src"}"#),
            "search_files · fn main"
        );
        assert_eq!(
            tool_title("glob", r#"{"pattern":"**/*.rs"}"#),
            "glob · **/*.rs"
        );
    }

    #[test]
    fn tool_title_mcp_tools_show_server_and_bare_tool_name() {
        assert_eq!(
            tool_title("mcp__sigit__list_issues", r#"{"repo":"getsigit/sigit"}"#),
            "sigit · list_issues"
        );
    }

    #[test]
    fn tool_title_unknown_tool_is_the_name_alone() {
        assert_eq!(
            tool_title("write_todos", r#"{"todos":[{"text":"x"}]}"#),
            "write_todos"
        );
    }

    #[test]
    fn tool_title_malformed_json_falls_back_to_the_name() {
        assert_eq!(tool_title("run_command", "{not json"), "run_command");
        assert_eq!(tool_title("edit_file", ""), "edit_file");
    }

    #[test]
    fn tool_title_missing_or_non_string_arg_falls_back_to_the_name() {
        assert_eq!(tool_title("run_command", r#"{"other":"x"}"#), "run_command");
        assert_eq!(
            tool_title("run_command", r#"{"command":42}"#),
            "run_command"
        );
    }

    #[test]
    fn tool_title_truncates_long_summaries_with_an_ellipsis() {
        let long = "a".repeat(100);
        let title = tool_title("run_command", &format!(r#"{{"command":"{long}"}}"#));

        let summary = title.strip_prefix("run_command · ").expect("summary");
        assert_eq!(summary.chars().count(), 60);
        assert!(summary.ends_with('…'));
    }

    #[test]
    fn tool_title_collapses_multiline_commands_to_one_line() {
        assert_eq!(
            tool_title("run_command", "{\"command\":\"echo a &&\\n  echo b\"}"),
            "run_command · echo a && echo b"
        );
    }

    // ── cap_output_preview ────────────────────────────────────────────────────

    #[test]
    fn cap_output_preview_passes_short_output_through() {
        assert_eq!(cap_output_preview("hello\nworld"), "hello\nworld");
    }

    #[test]
    fn cap_output_preview_keeps_the_tail_and_notes_the_truncation() {
        let output = format!("{}{}", "x".repeat(3000), "the-very-end");
        let preview = cap_output_preview(&output);

        assert!(preview.starts_with("… (truncated — showing the last 2000 of 3012 chars)\n"));
        assert!(preview.ends_with("the-very-end"));
        let (_note, tail) = preview.split_once('\n').expect("note line");
        assert_eq!(tail.chars().count(), TOOL_RESULT_PREVIEW_MAX);
    }

    // ── pretty_tool_arguments ─────────────────────────────────────────────────

    #[test]
    fn pretty_tool_arguments_pretty_prints_valid_json() {
        assert_eq!(
            pretty_tool_arguments(r#"{"command":"ls"}"#),
            "{\n  \"command\": \"ls\"\n}"
        );
    }

    #[test]
    fn pretty_tool_arguments_shows_malformed_json_raw() {
        assert_eq!(pretty_tool_arguments("{oops"), "{oops");
    }

    // ── tool_entry_lines ──────────────────────────────────────────────────────

    #[test]
    fn tool_entry_lines_collapsed_is_a_single_title_line() {
        let lines = tool_entry_lines(
            "run_command · cargo test",
            "{\n  \"command\": \"cargo test\"\n}",
            Some("ok"),
            false,
        );
        assert_eq!(lines, vec!["▸ 🔧 run_command · cargo test"]);
    }

    #[test]
    fn tool_entry_lines_expanded_shows_detail_and_result() {
        let lines = tool_entry_lines(
            "run_command · cargo test",
            "{\n  \"command\": \"cargo test\"\n}",
            Some("test ok\ndone"),
            true,
        );
        assert_eq!(
            lines,
            vec![
                "▾ 🔧 run_command · cargo test",
                "    {",
                "      \"command\": \"cargo test\"",
                "    }",
                "    result:",
                "      test ok",
                "      done",
            ]
        );
    }

    #[test]
    fn tool_entry_lines_expanded_without_result_omits_the_result_block() {
        let lines = tool_entry_lines("glob · **/*.rs", "{}", None, true);
        assert_eq!(lines, vec!["▾ 🔧 glob · **/*.rs", "    {}"]);
    }

    // ── Tab / format_age / history_row ────────────────────────────────────────

    #[test]
    fn tab_next_cycles_session_history_cloud() {
        assert_eq!(Tab::Session.next(), Tab::History);
        assert_eq!(Tab::History.next(), Tab::Cloud);
        assert_eq!(Tab::Cloud.next(), Tab::Session);
        // Three hops return to the start, matching the tab bar's order.
        assert_eq!(Tab::Session.next().next().next(), Tab::Session);
    }

    #[test]
    fn tab_index_matches_titles_order() {
        assert_eq!(Tab::TITLES[Tab::Session.index()], "Session");
        assert_eq!(Tab::TITLES[Tab::History.index()], "History");
        assert_eq!(Tab::TITLES[Tab::Cloud.index()], "Cloud");
    }

    #[test]
    fn format_age_picks_the_coarsest_sensible_unit() {
        assert_eq!(format_age(Duration::from_secs(0)), "0s ago");
        assert_eq!(format_age(Duration::from_secs(59)), "59s ago");
        assert_eq!(format_age(Duration::from_secs(60)), "1m ago");
        assert_eq!(format_age(Duration::from_secs(3_599)), "59m ago");
        assert_eq!(format_age(Duration::from_secs(3_600)), "1h ago");
        assert_eq!(format_age(Duration::from_secs(86_399)), "23h ago");
        assert_eq!(format_age(Duration::from_secs(86_400)), "1d ago");
        assert_eq!(format_age(Duration::from_secs(3 * 86_400)), "3d ago");
    }

    #[test]
    fn history_row_formats_id_age_and_count() {
        assert_eq!(
            history_row("tui", Some(Duration::from_secs(120)), 7),
            "tui  ·  2m ago  ·  7 message(s)"
        );
        assert_eq!(
            history_row("sess-1", None, 0),
            "sess-1  ·  age unknown  ·  0 message(s)"
        );
    }

    #[test]
    fn strip_think_blocks_separates_thinking_and_visible_reply() {
        let raw = "<think>I should inspect the code first.</think>Here is the fix.";
        let (thinking, visible) = strip_think_blocks(raw);

        assert_eq!(thinking, "I should inspect the code first.");
        assert_eq!(visible, "Here is the fix.");
    }

    #[test]
    fn strip_think_blocks_handles_unclosed_think_block() {
        let raw = "<think>I am still reasoning about the bug";
        let (thinking, visible) = strip_think_blocks(raw);

        assert_eq!(thinking, "I am still reasoning about the bug");
        assert_eq!(visible, "");
    }

    #[test]
    fn strip_think_blocks_leaves_plain_text_untouched() {
        let raw = "No hidden reasoning here.";
        let (thinking, visible) = strip_think_blocks(raw);

        assert_eq!(thinking, "");
        assert_eq!(visible, "No hidden reasoning here.");
    }

    #[test]
    fn streaming_think_tail_shows_last_lines_of_unclosed_think_buffer() {
        // the common mid-stream shape: `<think>` opened, not yet closed
        let raw = "<think>First thought.\nSecond thought.\nThird thought.\nFourth thought.";
        let tail = streaming_think_tail(raw, 40, 3);

        assert_eq!(
            tail,
            vec!["Second thought.", "Third thought.", "Fourth thought."]
        );
    }

    #[test]
    fn streaming_think_tail_wraps_long_lines_at_width() {
        let raw = "<think>one two three four five six seven";
        let tail = streaming_think_tail(raw, 10, 3);

        // wrapped at 10 cols: ["one two", "three four", "five six", "seven"];
        // the tail keeps only the last 3
        assert_eq!(tail, vec!["three four", "five six", "seven"]);
    }

    #[test]
    fn streaming_think_tail_is_empty_once_visible_text_arrives() {
        let raw = "<think>hidden reasoning</think>The answer is 42.";
        assert!(streaming_think_tail(raw, 40, 3).is_empty());
    }

    #[test]
    fn streaming_think_tail_is_empty_for_only_think_closed_but_no_visible_yet() {
        // closed think, visible not started: still show the reasoning tail
        let raw = "<think>done reasoning</think>";
        assert_eq!(streaming_think_tail(raw, 40, 3), vec!["done reasoning"]);
    }

    #[test]
    fn streaming_think_tail_is_empty_for_empty_or_plain_buffers() {
        assert!(streaming_think_tail("", 40, 3).is_empty());
        assert!(streaming_think_tail("plain visible text", 40, 3).is_empty());
        assert!(streaming_think_tail("<think>", 40, 3).is_empty());
    }

    #[test]
    fn streaming_think_tail_hard_splits_words_wider_than_the_wrap_column() {
        let raw = "<think>abcdefghijkl";
        assert_eq!(
            streaming_think_tail(raw, 5, 3),
            vec!["abcde", "fghij", "kl"]
        );
    }

    #[test]
    fn think_indicator_counts_lines_and_pluralizes() {
        assert_eq!(
            think_indicator("only one line of reasoning"),
            "· thought for a bit (1 line) — /thinking to show"
        );
        assert_eq!(
            think_indicator("a\nb\nc"),
            "· thought for a bit (3 lines) — /thinking to show"
        );
    }

    #[test]
    fn parse_rich_text_segments_marks_bold_runs() {
        let segments = parse_rich_text_segments(
            "The current weather is **72°F** with **Partly Cloudy** conditions.",
        );

        assert_eq!(
            segments,
            vec![
                ("The current weather is ".to_string(), false),
                ("72°F".to_string(), true),
                (" with ".to_string(), false),
                ("Partly Cloudy".to_string(), true),
                (" conditions.".to_string(), false),
            ]
        );
    }

    #[test]
    fn parse_rich_text_segments_treats_unclosed_marker_as_bold_to_end() {
        let segments = parse_rich_text_segments("Prefix **bold");

        assert_eq!(
            segments,
            vec![("Prefix ".to_string(), false), ("bold".to_string(), true),]
        );
    }
}
