//! Codex-style interactive TUI: header card, streaming assistant cells,
//! `• Ran cmd` / `└ output` exec cells, approval prompts, slash commands,
//! token-usage footer.

mod render;

use crate::agent::{Agent, AgentEvent, ApprovalDecision, ApprovalRegistry};
use crate::config::{
    apply_model, remove_mcp_server, save_config_values, save_mcp_server, ApprovalPolicy, Config,
    Provider, MODEL_PRESETS,
};

/// Memory cap for rendered cell text. Beyond this, the visible cell is
/// truncated with a "…" marker. The full transcript is still compacted by
/// `max_cells`, so long-running sessions stay bounded.
const MAX_ASSISTANT_TEXT_LEN: usize = 128_000;
const MAX_REASONING_TEXT_LEN: usize = 32_000;
const MAX_NOTICE_TEXT_LEN: usize = 8_000;
use crate::hardware::{self, find_cloud, find_local, Hardware, CLOUD_MODELS, LOCAL_MODELS};
use anyhow::Result;
use crossterm::event::{
    DisableBracketedPaste, EnableBracketedPaste, Event, KeyCode, KeyEvent, KeyEventKind,
    KeyModifiers,
};
use crossterm::execute;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use ratatui::backend::CrosstermBackend;
use ratatui::text::Line;
use ratatui::widgets::{Paragraph, Widget};
use ratatui::{Terminal, TerminalOptions, Viewport};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use tokio::sync::mpsc;
use tokio_util::sync::CancellationToken;

pub enum ExecStatus {
    Running,
    Ok,
    Failed(i32),
}

/// Events produced by UI-side async jobs (downloads, login, ollama probes).
pub enum UiEvent {
    /// Hardware detection finished on a background thread (deferred off the
    /// startup path so a slow `nvidia-smi`/`powershell` can't block input).
    HardwareDetected(Hardware),
    OllamaTags(Vec<String>),
    OllamaMissing,
    DownloadProgress { tag: String, line: String },
    DownloadDone { tag: String, ok: bool },
    OllamaInstalled { ok: bool },
    SkillsResult(String),
    DeleteDone { tag: String, ok: bool, message: String },
    LoginResult { ok: bool, message: String, token: String, email: String },
    /// Codex / "Sign in with ChatGPT" finished. On success the tokens are
    /// already on disk; `account` is the signed-in account label.
    CodexLoginResult { ok: bool, message: String, account: String },
    /// The ChatGPT authorize URL, shown as a fallback if the browser didn't open.
    CodexLoginUrl(String),
    CreditsResult(String),
    /// A newer published version is available.
    UpdateAvailable { latest: String },
    /// The OpenRouter model→context cache was refreshed from `/api/v1/models`,
    /// so a non-preset model's context window may now be known exactly.
    OpenRouterModelsRefreshed,
}

/// One autocomplete entry: `insert` replaces the whole input when accepted.
pub struct Suggestion {
    pub insert: String,
    pub label: String,
    pub detail: String,
}

pub const COMMANDS: &[(&str, &str)] = &[
    ("/login", "sign in with Google to run frontier cloud models — no API keys, billed from your plan; /login codex for ChatGPT"),
    ("/agent", "agent mode — full tools (default)"),
    ("/plan", "plan mode — propose only, no tools run"),
    ("/research", "research mode — web search + read only"),
    ("/setup", "hardware check + model catalog"),
    ("/download", "pull a local model via Ollama"),
    ("/delete", "remove a downloaded local model"),
    ("/model", "switch model"),
    ("/models", "list model presets"),
    ("/apikey", "save an OpenRouter/OpenAI key"),
    ("/credits", "show plan & balance; /credits subscribe <starter|pro|max>"),
    ("/logout", "sign out of Lucid Cloud"),
    ("/effort", "reasoning effort for GPT/Codex: low | medium | high | xhigh"),
    ("/onboarding", "re-open the first-run setup wizard"),
    ("/provider", "switch provider"),
    ("/approvals", "plan | auto | full-auto"),
    ("/skills", "list available skills"),
    ("/mcp", "list/add/remove MCP servers"),
    ("/attach", "attach an image to your next message (vision models)"),
    ("/copy", "copy last reply / output / all to clipboard"),
    ("/update", "check for a new version / install it"),
    ("/status", "session configuration"),
    ("/compact", "compact context now"),
    ("/new", "start a fresh session"),
    ("/help", "show help"),
    ("/quit", "exit"),
];

pub enum Cell {
    Header,
    Help,
    Setup,
    Download { tag: String, line: String, done: bool, ok: bool },
    User(String),
    Assistant(String),
    Reasoning { text: String, done: bool, seed: u32 },
    /// Codex-style plan checklist: (step, status).
    Plan(Vec<(String, String)>),
    /// A reviewable file edit shown as a colored diff.
    Diff {
        path: String,
        op: &'static str,
        added: usize,
        removed: usize,
        lines: Vec<crate::tools::edit::DiffLine>,
    },
    Exec {
        call_id: String,
        command: String,
        description: String,
        output_tail: String,
        status: ExecStatus,
        duration_ms: u128,
        background: bool,
    },
    Notice(String),
    Error(String),
}

/// An inline value the TUI is waiting for (e.g. an API key). When set, the
/// composer becomes a labeled prompt instead of a chat input.
pub struct PendingInput {
    pub label: String,
    pub hint: String,
    pub masked: bool,
    pub action: InputAction,
}

pub enum InputAction {
    /// Save an OpenRouter API key, then optionally switch to `model`.
    ApiKey { model: Option<String> },
}

/// First-run onboarding wizard: a small menu shown after the intro when no
/// provider is configured. Each option routes into an existing flow (/login,
/// the API-key prompt, or /download) so there's one code path per action.
pub struct Onboard {
    pub selected: usize,
}

/// The onboarding choices, in display order. `(title, blurb)`.
pub const ONBOARD_OPTIONS: &[(&str, &str)] = &[
    ("Connect ChatGPT / Codex", "use your ChatGPT subscription — GPT-5.5, Codex (sign in, no API key)"),
    ("Use an OpenRouter key", "Tencent Hy3 (free preview), Kimi, Claude, Gemini, GPT, DeepSeek… (paste a key)"),
    ("Run a model locally, free", "downloads a model that fits this machine via Ollama — private, offline"),
    ("Explore on my own", "skip setup — /setup, /model, /login, /help are always available"),
];

/// What the y/n modal is asking about.
#[derive(PartialEq)]
pub enum ApprovalKind {
    /// A tool/command the agent wants to run (responds via the approval
    /// registry, keyed by request id).
    Tool,
    /// Confirm switching the approval policy to full-auto.
    FullAuto,
    /// Plan mode finished — offer to switch to agent mode and implement.
    ImplementPlan,
    /// A newer version is available — offer to update or skip.
    Update { latest: String, command: String },
    /// A cloud model needs auth — offer Lucid Cloud sign-in (y) or fall back to
    /// pasting an OpenRouter key (n).
    LoginOrKey { model: String },
}

pub struct PendingApproval {
    pub kind: ApprovalKind,
    pub command: String,
    pub risk_label: String,
    pub reason: String,
    /// Registry id of the agent's approval request (Tool kind only) —
    /// resolved via `App::approval_registry`.
    pub request_id: Option<String>,
}

enum WorkerCmd {
    UserMessage(String),
    /// User message carrying attached image data URLs (for vision models).
    UserMessageImages(String, Vec<String>),
    Reconfigure(Config),
    ClearHistory,
    Compact,
    Shutdown,
}

pub struct App {
    pub cfg: Config,
    pub cells: Vec<Cell>,
    /// Rolling plain-text transcript kept for `/copy all` even when the
    /// rendered cell cache is trimmed to save memory.
    pub transcript: Vec<String>,
    /// Hard cap on in-memory cells. Older cells are archived to
    /// `transcript` and dropped from the rendered Vec.
    pub max_cells: usize,
    pub input: String,
    pub cursor: usize,
    pub running: bool,
    pub spinner_idx: usize,
    pub approval: Option<PendingApproval>,
    /// Resolves the agent's pending `ApprovalRequest`s by id (shared with the
    /// agent worker).
    pub approval_registry: ApprovalRegistry,
    pub tokens_used: u32,
    pub input_history: Vec<String>,
    pub hist_idx: Option<usize>,
    pub assistant_streaming: bool,
    pub reasoning_streaming: bool,
    pub hardware: Hardware,
    /// False until the background `hardware::detect()` task reports back, so the
    /// onboarding/setup panes can show "detecting…" instead of an empty list.
    pub hardware_detected: bool,
    pub ollama_available: bool,
    pub ollama_tags: Vec<String>,
    pub session_input: u64,
    pub session_output: u64,
    pub suggestions: Vec<Suggestion>,
    pub sugg_idx: usize,
    /// Model download queued behind an in-flight ollama install.
    pending_download: Option<String>,
    ollama_installing: bool,
    /// Double-Esc quit tracking.
    last_esc: Option<std::time::Instant>,
    /// Image paths attached for the next message (sent to vision models).
    pub attachments: Vec<String>,
    /// Pasted multi-line blocks, shown in the composer as compact
    /// `[#id pasted N lines]` placeholders and expanded back into the message
    /// text on submit. `(id, content)`.
    pub pastes: Vec<(u32, String)>,
    /// Monotonic id for the next paste block (reset after each message).
    pub paste_seq: u32,
    /// When set, the composer collects a value (e.g. an API key) instead of
    /// sending a chat message.
    pub pending_input: Option<PendingInput>,
    /// First-run onboarding wizard (shown after the intro until dismissed).
    pub onboarding: Option<Onboard>,
    /// Model to auto-switch to once its background download finishes (set by
    /// the onboarding "run locally" path).
    pub pending_auto_model: Option<String>,
    /// Codex model the user asked for while signed out — applied once the
    /// auto-triggered login completes.
    pub pending_codex_model: Option<String>,
    /// True while a "Sign in with ChatGPT" browser flow is running, so the UI
    /// shows an animated "waiting for browser" status instead of looking frozen,
    /// and re-triggering the login is a no-op (avoids a second localhost:1455 bind).
    pub login_in_flight: bool,
    /// Startup train animation: tick counter while active.
    pub intro_ticks: Option<u32>,
    ui_tx: mpsc::UnboundedSender<UiEvent>,
    quit: bool,
}

impl App {
    fn new(cfg: Config, hardware: Hardware, ui_tx: mpsc::UnboundedSender<UiEvent>) -> Self {
        // First run = nothing configured yet AND not previously dismissed. Show
        // the interactive onboarding wizard after the intro; the Help cell sits
        // behind it as the backdrop once dismissed. A deliberate skip persists
        // `onboarded` so we don't nag on every launch (re-open with /onboarding).
        let needs_setup = !cfg.onboarded && cfg.api_key.is_empty() && cfg.lucid_token.is_empty();
        let cells = vec![Cell::Header, Cell::Help];
        let onboarding = needs_setup.then(|| Onboard { selected: 0 });
        App {
            cfg,
            cells,
            transcript: Vec::new(),
            max_cells: 250,
            input: String::new(),
            cursor: 0,
            running: false,
            spinner_idx: 0,
            approval: None,
            approval_registry: ApprovalRegistry::new(),
            tokens_used: 0,
            input_history: Vec::new(),
            hist_idx: None,
            assistant_streaming: false,
            reasoning_streaming: false,
            hardware,
            hardware_detected: false,
            ollama_available: false,
            ollama_tags: Vec::new(),
            session_input: 0,
            session_output: 0,
            suggestions: Vec::new(),
            sugg_idx: 0,
            pending_download: None,
            ollama_installing: false,
            last_esc: None,
            attachments: Vec::new(),
            pastes: Vec::new(),
            paste_seq: 0,
            pending_input: None,
            onboarding,
            pending_auto_model: None,
            pending_codex_model: None,
            login_in_flight: false,
            intro_ticks: Some(0),
            ui_tx,
            quit: false,
        }
    }

    fn tag_installed(&self, tag: &str) -> bool {
        self.ollama_tags
            .iter()
            .any(|t| t == tag || t.trim_end_matches(":latest") == tag)
    }

    /// Recompute the autocomplete popup from the current input.
    fn refresh_suggestions(&mut self) {
        self.suggestions = compute_suggestions(self);
        if self.sugg_idx >= self.suggestions.len() {
            self.sugg_idx = 0;
        }
    }

    fn push_notice(&mut self, text: impl Into<String>) {
        let cell = Cell::Notice(text.into());
        self.add_cell(cell);
    }

    fn add_cell(&mut self, cell: Cell) {
        self.transcript.push(render::cell_to_plain(&cell));
        self.cells.push(cell);
        self.cap_cells();
        self.trim_cells();
    }

    /// Enforce per-cell text ceilings so long streaming outputs can't grow
    /// the cell buffers without bound. The transcript is capped too because
    /// it is the source for `/copy all`.
    fn cap_cells(&mut self) {
        for cell in self.cells.iter_mut() {
            match cell {
                Cell::Assistant(t) => cap_text(t, MAX_ASSISTANT_TEXT_LEN),
                Cell::Reasoning { text, .. } => cap_text(text, MAX_REASONING_TEXT_LEN),
                Cell::Notice(t) | Cell::Error(t) => cap_text(t, MAX_NOTICE_TEXT_LEN),
                _ => {}
            }
        }
        for t in self.transcript.iter_mut() {
            cap_text(t, MAX_ASSISTANT_TEXT_LEN);
        }
    }

    fn trim_cells(&mut self) {
        if self.cells.len() <= self.max_cells {
            return;
        }
        let keep = self
            .cells
            .iter()
            .rposition(|c| matches!(c, Cell::Header | Cell::Help | Cell::Setup))
            .map(|i| i + 1)
            .unwrap_or(0);
        let surplus = self.cells.len().saturating_sub(self.max_cells);
        let drain_to = (keep + surplus).min(self.cells.len());
        if drain_to > keep {
            self.cells.drain(keep..drain_to);
        }
    }

    /// Collapse the live thinking cell once visible output starts.
    fn finish_reasoning(&mut self) {
        self.reasoning_streaming = false;
        for cell in self.cells.iter_mut().rev() {
            if let Cell::Reasoning { done, .. } = cell {
                if !*done {
                    *done = true;
                }
                break;
            }
        }
    }

    fn append_assistant(&mut self, delta: &str) {
        self.finish_reasoning();
        if let Some(Cell::Assistant(s)) = self.cells.last_mut() {
            if self.assistant_streaming {
                s.push_str(&render::sanitize_display(delta));
                cap_text(s, MAX_ASSISTANT_TEXT_LEN);
                return;
            }
        }
        self.add_cell(Cell::Assistant(render::sanitize_display(delta).to_string()));
        self.assistant_streaming = true;
    }

    fn append_reasoning(&mut self, delta: &str) {
        if let Some(Cell::Reasoning { text, .. }) = self.cells.last_mut() {
            if self.reasoning_streaming {
                text.push_str(&render::sanitize_display(delta));
                cap_text(text, MAX_REASONING_TEXT_LEN);
                return;
            }
        }
        // Seed fixes this cell's dream-term for its whole lifetime.
        let seed = self.cells.len() as u32 + self.input_history.len() as u32 * 131;
        self.add_cell(Cell::Reasoning {
            text: render::sanitize_display(delta).to_string(),
            done: false,
            seed,
        });
        self.reasoning_streaming = true;
    }
}

/// Max rows the live region (in-progress, not-yet-committed content) may occupy
/// in the inline viewport while a turn runs. Older content has already gone to
/// the terminal's scrollback; anything beyond this is tail-clipped on screen but
/// still committed in full the moment it settles.
const LIVE_CAP: u16 = 12;

/// Inline-viewport height. Driven by coarse, stable layout states so the
/// `Terminal` is recreated only a few times per turn (turn start/end, overlay
/// show/hide, resize, intro→main) rather than per streamed line or per keystroke.
fn desired_viewport_height(app: &App, term_h: u16) -> u16 {
    let cap = term_h.saturating_sub(1).max(1);
    // The intro/onboarding splashes paint full-screen.
    if app.intro_ticks.is_some() || app.onboarding.is_some() {
        return cap;
    }
    let approval_h = if app.approval.is_some() { 6 } else { 0 };
    // Fixed reservation when the autocomplete popup is shown, so per-keystroke
    // changes in the match count don't resize the viewport.
    let sugg_h = if app.approval.is_none() && !app.suggestions.is_empty() { 7 } else { 0 };
    let chrome = 3 + 1 + approval_h + sugg_h; // input + status + overlays
    let live = if app.running { LIVE_CAP } else { 1 };
    (chrome + live).min(cap).max(1)
}

/// Push the given cells into the terminal's scrollback via `insert_before`,
/// oldest first. Each is rendered with the same per-cell layout the live
/// viewport uses (`render_cell_block`), so spacing matches on screen and in
/// scrollback. `insert_before` handles cells taller than the screen.
fn commit_cells<B: ratatui::backend::Backend>(
    terminal: &mut Terminal<B>,
    app: &App,
    cells: Vec<Cell>,
    width: usize,
) -> std::io::Result<()> {
    for cell in cells {
        let mut lines: Vec<Line> = Vec::new();
        render::render_cell_block(&cell, app, width, &mut lines);
        let h = lines.len() as u16;
        if h == 0 {
            continue;
        }
        terminal.insert_before(h, move |buf| {
            Paragraph::new(lines).render(buf.area, buf);
        })?;
    }
    Ok(())
}

/// Number of leading cells that are guaranteed never to mutate again, so they
/// can be committed to the terminal's scrollback. Counting stops at the first
/// still-live cell — this preserves transcript order, so a settled cell sitting
/// behind a live one (e.g. a finished tool after a still-streaming reply) waits
/// its turn rather than jumping ahead into scrollback.
fn settled_prefix_len(app: &App) -> usize {
    let last = app.cells.len();
    for (i, cell) in app.cells.iter().enumerate() {
        let settled = match cell {
            Cell::Header
            | Cell::Help
            | Cell::Setup
            | Cell::User(_)
            | Cell::Notice(_)
            | Cell::Error(_)
            | Cell::Diff { .. } => true,
            Cell::Reasoning { done, .. } => *done,
            Cell::Exec { status, .. } => !matches!(status, ExecStatus::Running),
            Cell::Download { done, .. } => *done,
            // Streaming only ever appends to the LAST Assistant cell, so any
            // earlier one — or any time streaming has stopped — is settled.
            Cell::Assistant(_) => !(app.assistant_streaming && i + 1 == last),
            // A plan checklist is replaced in place during a turn; it can only
            // settle once the turn ends (`running` cleared by TurnComplete).
            Cell::Plan(_) => !app.running,
        };
        if !settled {
            return i;
        }
    }
    last
}

/// Build autocomplete suggestions for the current input. Models are sorted
/// best → worst (quality rank), with unrunnable ones last.
fn compute_suggestions(app: &App) -> Vec<Suggestion> {
    let input = app.input.as_str();
    if !input.starts_with('/') || input.contains('\n') {
        return Vec::new();
    }

    let (cmd, rest) = match input.split_once(' ') {
        Some((c, r)) => (c, Some(r.trim_start())),
        None => (input, None),
    };

    // Completing the command itself.
    let Some(arg) = rest else {
        return COMMANDS
            .iter()
            .filter(|(c, _)| c.starts_with(cmd))
            .map(|(c, d)| Suggestion {
                insert: format!("{c} "),
                label: c.to_string(),
                detail: d.to_string(),
            })
            .collect();
    };

    let arg_lower = arg.to_ascii_lowercase();
    let matches = |s: &str| arg.is_empty() || s.to_ascii_lowercase().contains(&arg_lower);
    let budget = app.hardware.budget_gb();

    match cmd {
        "/download" | "/pull" => {
            let mut models: Vec<&'static crate::hardware::LocalModel> =
                LOCAL_MODELS.iter().filter(|m| matches(m.tag)).collect();
            models.sort_by(|a, b| {
                let fits_a = a.min_ram_gb <= budget;
                let fits_b = b.min_ram_gb <= budget;
                fits_b
                    .cmp(&fits_a)
                    .then(b.tool_calling.cmp(&a.tool_calling))
                    .then(b.quality.cmp(&a.quality))
            });
            models
                .into_iter()
                .map(|m| {
                    let fit = if app.tag_installed(m.tag) {
                        "installed"
                    } else if m.min_ram_gb > budget {
                        "too big"
                    } else if app.hardware.tight_now(m.min_ram_gb) {
                        "fits (low free RAM)"
                    } else {
                        "fits"
                    };
                    let tools = if m.tool_calling { "" } else { " • no tools" };
                    Suggestion {
                        insert: format!("/download {}", m.tag),
                        label: m.tag.to_string(),
                        detail: format!("{:.1} GB • {fit}{tools} • {}", m.download_gb, m.note),
                    }
                })
                .collect()
        }
        "/delete" => app
            .ollama_tags
            .iter()
            .filter(|t| matches(t))
            .map(|t| Suggestion {
                insert: format!("/delete {t}"),
                label: t.clone(),
                detail: "remove from disk".into(),
            })
            .collect(),
        "/model" => {
            // Cloud + local merged, best first; installed local models float up.
            let mut items: Vec<(u8, bool, Suggestion)> = Vec::new();
            for c in CLOUD_MODELS.iter().filter(|c| matches(c.alias) || matches(c.id)) {
                items.push((
                    c.quality,
                    false,
                    Suggestion {
                        insert: format!("/model {}", c.alias),
                        label: c.alias.to_string(),
                        detail: format!("openrouter • {} • {}", c.family, c.id),
                    },
                ));
            }
            // ChatGPT/Codex subscription models (config presets, not the cloud
            // catalog). Float them just under the top OpenRouter picks.
            for p in crate::config::MODEL_PRESETS
                .iter()
                .filter(|p| p.provider == Provider::Codex && (matches(p.alias) || matches(p.id)))
            {
                items.push((
                    200,
                    false,
                    Suggestion {
                        insert: format!("/model {}", p.alias),
                        label: p.alias.to_string(),
                        detail: "chatgpt subscription • run /login".into(),
                    },
                ));
            }
            for m in LOCAL_MODELS.iter().filter(|m| matches(m.tag)) {
                let installed = app.tag_installed(m.tag);
                let runnable = m.min_ram_gb <= budget;
                let detail = if installed {
                    format!("local • installed • {}", m.note)
                } else if runnable {
                    format!("local • needs /download • {}", m.note)
                } else {
                    "local • too big for this machine".to_string()
                };
                items.push((
                    if runnable { m.quality } else { 0 },
                    installed,
                    Suggestion {
                        insert: format!("/model {}", m.tag),
                        label: m.tag.to_string(),
                        detail,
                    },
                ));
            }
            items.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.cmp(&a.0)));
            items.into_iter().map(|(_, _, s)| s).collect()
        }
        "/approvals" | "/approval" => ["plan", "auto", "full-auto"]
            .iter()
            .filter(|p| matches(p))
            .map(|p| Suggestion {
                insert: format!("/approvals {p}"),
                label: p.to_string(),
                detail: match *p {
                    "plan" => "propose only — every command asks".into(),
                    "auto" => "safe commands auto-run; risky ones ask".into(),
                    _ => "auto-approve everything except blocked".into(),
                },
            })
            .collect(),
        "/provider" => ["openrouter", "ollama", "openai", "codex", "custom"]
            .iter()
            .filter(|p| matches(p))
            .map(|p| Suggestion {
                insert: format!("/provider {p}"),
                label: p.to_string(),
                detail: if *p == "codex" { "ChatGPT subscription — /login".into() } else { String::new() },
            })
            .collect(),
        "/effort" | "/reasoning" => ["low", "medium", "high", "xhigh"]
            .iter()
            .filter(|p| matches(p))
            .map(|p| Suggestion {
                insert: format!("/effort {p}"),
                label: p.to_string(),
                detail: match *p {
                    "low" => "fastest, least thinking".into(),
                    "high" => "more thorough, slower".into(),
                    "xhigh" => "maximum reasoning (Codex)".into(),
                    _ => "balanced (default)".into(),
                },
            })
            .collect(),
        "/skills" => ["list", "search ", "install "]
            .iter()
            .filter(|p| matches(p.trim()))
            .map(|p| Suggestion {
                insert: format!("/skills {p}"),
                label: p.trim().to_string(),
                detail: match p.trim() {
                    "search" => "find skills on skills.sh".into(),
                    "install" => "/skills install <owner/repo> [skill]".into(),
                    _ => "show installed skills".into(),
                },
            })
            .collect(),
        "/mcp" => ["list", "add ", "remove "]
            .iter()
            .filter(|p| matches(p.trim()))
            .map(|p| Suggestion {
                insert: format!("/mcp {p}"),
                label: p.trim().to_string(),
                detail: match p.trim() {
                    "add" => "/mcp add <name> <command> [args…]".into(),
                    "remove" => "/mcp remove <name>".into(),
                    _ => "show configured servers & tools".into(),
                },
            })
            .collect(),
        "/copy" => ["last", "output", "all"]
            .iter()
            .filter(|p| matches(p))
            .map(|p| Suggestion {
                insert: format!("/copy {p}"),
                label: p.to_string(),
                detail: match *p {
                    "last" => "last assistant reply".into(),
                    "output" => "last command + output".into(),
                    _ => "entire transcript".into(),
                },
            })
            .collect(),
        // "/credits" autocomplete hidden — Lucid Cloud disabled for now.
        _ => Vec::new(),
    }
}

/// Copy text to the system clipboard: pbcopy (macOS), wl-copy/xclip (Linux),
/// clip (Windows). No extra crates needed.
fn copy_to_clipboard(text: &str) -> Result<(), String> {
    use std::io::Write;
    use std::process::{Command, Stdio};
    let candidates: &[(&str, &[&str])] = match std::env::consts::OS {
        "macos" => &[("pbcopy", &[])],
        "windows" => &[("clip", &[])],
        _ => &[("wl-copy", &[]), ("xclip", &["-selection", "clipboard"])],
    };
    for (cmd, args) in candidates {
        if let Ok(mut child) = Command::new(cmd)
            .args(*args)
            .stdin(Stdio::piped())
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .spawn()
        {
            if let Some(stdin) = child.stdin.as_mut() {
                if stdin.write_all(text.as_bytes()).is_ok() {
                    drop(child.stdin.take());
                    if child.wait().map(|s| s.success()).unwrap_or(false) {
                        return Ok(());
                    }
                }
            }
        }
    }
    Err("no clipboard tool found (pbcopy/wl-copy/xclip/clip)".into())
}

/// Start `ollama serve` tuned for low memory pressure so other apps stay
/// usable during inference:
/// - flash attention + q8_0 KV cache → roughly halves KV memory at 16k ctx
/// - one model / one request at a time
/// - models unload after 4 minutes idle, freeing RAM between turns
pub fn spawn_ollama_serve() {
    let _ = std::process::Command::new("ollama")
        .arg("serve")
        .env("OLLAMA_FLASH_ATTENTION", "1")
        .env("OLLAMA_KV_CACHE_TYPE", "q8_0")
        .env("OLLAMA_CONTEXT_LENGTH", crate::config::OLLAMA_RUNTIME_CONTEXT_TOKENS.to_string())
        .env("OLLAMA_MAX_LOADED_MODELS", "1")
        .env("OLLAMA_NUM_PARALLEL", "1")
        .env("OLLAMA_KEEP_ALIVE", "4m")
        .stdout(std::process::Stdio::null())
        .stderr(std::process::Stdio::null())
        .spawn();
}

/// Probe the local Ollama server for installed models.
async fn fetch_ollama_tags(ui_tx: mpsc::UnboundedSender<UiEvent>) {
    let installed = std::process::Command::new("which")
        .arg("ollama")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false);
    if !installed {
        let _ = ui_tx.send(UiEvent::OllamaMissing);
        return;
    }
    let client = reqwest::Client::new();
    match client
        .get("http://localhost:11434/api/tags")
        .timeout(std::time::Duration::from_secs(3))
        .send()
        .await
    {
        Ok(resp) => {
            let tags = resp
                .json::<serde_json::Value>()
                .await
                .ok()
                .and_then(|v| {
                    v["models"].as_array().map(|arr| {
                        arr.iter()
                            .filter_map(|m| m["name"].as_str().map(String::from))
                            .collect::<Vec<_>>()
                    })
                })
                .unwrap_or_default();
            let _ = ui_tx.send(UiEvent::OllamaTags(tags));
        }
        Err(_) => {
            // Installed but server not running — start it memory-tuned.
            spawn_ollama_serve();
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
            if let Ok(resp) = client
                .get("http://localhost:11434/api/tags")
                .timeout(std::time::Duration::from_secs(3))
                .send()
                .await
            {
                let tags = resp
                    .json::<serde_json::Value>()
                    .await
                    .ok()
                    .and_then(|v| {
                        v["models"].as_array().map(|arr| {
                            arr.iter()
                                .filter_map(|m| m["name"].as_str().map(String::from))
                                .collect::<Vec<_>>()
                        })
                    })
                    .unwrap_or_default();
                let _ = ui_tx.send(UiEvent::OllamaTags(tags));
            } else {
                let _ = ui_tx.send(UiEvent::OllamaMissing);
            }
        }
    }
}

/// Refresh the OpenRouter model catalog cache so any model the user picks —
/// even an arbitrary `vendor/model` id that isn't a curated preset — gets its
/// real context window. Best-effort and offline-safe: on any error the cache is
/// simply left as-is and resolution falls back to the id heuristic.
async fn refresh_openrouter_models(ui_tx: mpsc::UnboundedSender<UiEvent>) {
    let url = format!("{}/models", crate::config::OPENROUTER_BASE_URL);
    let client = reqwest::Client::new();
    let resp = match client
        .get(&url)
        .timeout(std::time::Duration::from_secs(4))
        .send()
        .await
    {
        Ok(r) => r,
        Err(_) => return,
    };
    let Ok(body) = resp.json::<serde_json::Value>().await else { return };
    let Some(data) = body.get("data").and_then(|d| d.as_array()) else { return };
    let mut map = serde_json::Map::new();
    for m in data {
        if let (Some(id), Some(ctx)) = (
            m.get("id").and_then(|v| v.as_str()),
            m.get("context_length").and_then(|v| v.as_u64()),
        ) {
            if ctx > 0 {
                map.insert(id.to_string(), serde_json::Value::from(ctx));
            }
        }
    }
    if map.is_empty() {
        return;
    }
    let path = crate::config::openrouter_cache_path();
    if let Some(parent) = path.parent() {
        let _ = std::fs::create_dir_all(parent);
    }
    if std::fs::write(&path, serde_json::Value::Object(map).to_string()).is_ok() {
        let _ = ui_tx.send(UiEvent::OpenRouterModelsRefreshed);
    }
}

pub async fn run_tui(cfg: Config) -> Result<()> {
    // ---- spawn agent worker ----
    let (cmd_tx, mut cmd_rx) = mpsc::unbounded_channel::<WorkerCmd>();
    let (ev_tx, mut ev_rx) = mpsc::unbounded_channel::<AgentEvent>();
    let current_cancel: Arc<Mutex<CancellationToken>> =
        Arc::new(Mutex::new(CancellationToken::new()));

    let approval_registry = ApprovalRegistry::new();
    let worker_cancel = current_cancel.clone();
    let worker_cfg = cfg.clone();
    let worker_approvals = approval_registry.clone();
    let worker = tokio::spawn(async move {
        let mut agent = Agent::with_approvals(worker_cfg, worker_approvals);
        while let Some(cmd) = cmd_rx.recv().await {
            match cmd {
                WorkerCmd::UserMessage(text) => {
                    let token = CancellationToken::new();
                    *worker_cancel.lock().unwrap() = token.clone();
                    agent.run_turn(text, &ev_tx, &token).await;
                }
                WorkerCmd::UserMessageImages(text, images) => {
                    let token = CancellationToken::new();
                    *worker_cancel.lock().unwrap() = token.clone();
                    agent.run_turn_with_images(text, images, &ev_tx, &token).await;
                }
                WorkerCmd::Reconfigure(cfg) => {
                    let mcp_changed = cfg.mcp_servers.len() != agent.cfg.mcp_servers.len()
                        || cfg
                            .mcp_servers
                            .iter()
                            .zip(agent.cfg.mcp_servers.iter())
                            .any(|(a, b)| {
                                a.name != b.name
                                    || a.command != b.command
                                    || a.args != b.args
                                    || a.env != b.env
                            });
                    agent.reconfigure(cfg);
                    if mcp_changed {
                        agent.reset_mcp();
                    }
                }
                WorkerCmd::ClearHistory => agent.clear_history(),
                WorkerCmd::Compact => agent.compact(&ev_tx).await,
                WorkerCmd::Shutdown => {
                    agent.bg.shutdown().await;
                    break;
                }
            }
        }
    });

    // ---- terminal setup ----
    // Inline viewport, NOT the alternate screen: settled transcript lines are
    // pushed into the terminal's own scrollback via `insert_before`, and only the
    // live UI (in-progress output + input + status) occupies the bottom viewport.
    // Scrolling is the terminal's own (wheel / native scrollback); mouse capture
    // stays off so native select/copy and clickable links keep working.
    enable_raw_mode()?;
    // EnableBracketedPaste makes the terminal deliver a paste as ONE Event::Paste
    // instead of a burst of keystrokes (whose embedded newlines would otherwise
    // each submit the input as a separate message).
    execute!(std::io::stdout(), EnableBracketedPaste)?;
    // The Terminal itself is created just before the loop, once `app` exists, so
    // the initial inline height can reflect the first frame's layout.

    // ---- input thread ----
    // Poll-based (not a blocking `read()`) and pausable: creating/resizing an
    // inline viewport issues a cursor-position query (a DSR escape round-trip),
    // and a concurrent reader would swallow the response — the terminal then
    // reports "cursor position could not be read". `input_pause` lets the main
    // loop quiet this reader around every `Terminal::with_options` call. It
    // starts paused so the initial terminal can be created safely.
    let (key_tx, mut key_rx) = mpsc::unbounded_channel::<Event>();
    let input_pause = Arc::new(std::sync::atomic::AtomicBool::new(true));
    {
        let pause = input_pause.clone();
        std::thread::spawn(move || {
            use std::sync::atomic::Ordering;
            loop {
                if pause.load(Ordering::Relaxed) {
                    std::thread::sleep(Duration::from_millis(5));
                    continue;
                }
                match crossterm::event::poll(Duration::from_millis(20)) {
                    Ok(true) => match crossterm::event::read() {
                        Ok(ev) => {
                            if key_tx.send(ev).is_err() {
                                break;
                            }
                        }
                        Err(_) => break,
                    },
                    Ok(false) => {}
                    Err(_) => break,
                }
            }
        });
    }

    let (ui_tx, mut ui_rx) = mpsc::unbounded_channel::<UiEvent>();
    // Detect hardware OFF the startup path. `hardware::detect()` shells out to
    // nvidia-smi/vm_stat/powershell, any of which can stall for many seconds on
    // a fresh machine; running it here (on a blocking thread) keeps the event
    // loop responsive from the first frame. Start with a placeholder and fill in
    // the real result via UiEvent::HardwareDetected.
    let hw = Hardware { os: std::env::consts::OS, ..Default::default() };
    {
        let utx = ui_tx.clone();
        tokio::task::spawn_blocking(move || {
            let _ = utx.send(UiEvent::HardwareDetected(hardware::detect()));
        });
    }
    tokio::spawn(fetch_ollama_tags(ui_tx.clone()));
    // Refresh the OpenRouter context-window cache in the background so /model
    // picks up exact windows for non-preset ids (offline-safe; no-op on error).
    tokio::spawn(refresh_openrouter_models(ui_tx.clone()));

    // Background update check (silent if offline / no newer version).
    if cfg.update_check {
        let utx = ui_tx.clone();
        tokio::spawn(async move {
            if let Some(latest) = crate::update::latest_version().await {
                if crate::update::is_newer(&latest, crate::update::CURRENT) {
                    let _ = utx.send(UiEvent::UpdateAvailable { latest });
                }
            }
        });
    }

    let mut app = App::new(cfg, hw, ui_tx.clone());
    app.approval_registry = approval_registry;
    let mut last_size = crossterm::terminal::size().unwrap_or((80, 24));
    let mut viewport_h = desired_viewport_height(&app, last_size.1);
    // Safe to create now — the input reader is still paused (see above).
    let mut terminal = Terminal::with_options(
        CrosstermBackend::new(std::io::stdout()),
        TerminalOptions { viewport: Viewport::Inline(viewport_h) },
    )?;
    input_pause.store(false, std::sync::atomic::Ordering::Relaxed);
    let mut tick = tokio::time::interval(Duration::from_millis(80));
    let result: Result<()> = loop {
        let (term_w, term_h) = crossterm::terminal::size().unwrap_or((80, 24));
        let cwidth = term_w.saturating_sub(2).max(20) as usize;
        // 1) Resize the inline viewport when the coarse layout state changes
        //    (turn start/end, overlay show/hide, resize, intro→main) — never per
        //    streamed line — or when the terminal itself was resized, by recreating
        //    it at the new size FIRST (so the commit below renders at the right
        //    width). Recreating issues a cursor-position query, so the input reader
        //    is quieted around it to keep the DSR response from being swallowed.
        let desired = desired_viewport_height(&app, term_h);
        if desired != viewport_h || (term_w, term_h) != last_size {
            input_pause.store(true, std::sync::atomic::Ordering::Relaxed);
            tokio::time::sleep(Duration::from_millis(30)).await;
            terminal.clear()?;
            terminal = Terminal::with_options(
                CrosstermBackend::new(std::io::stdout()),
                TerminalOptions { viewport: Viewport::Inline(desired) },
            )?;
            input_pause.store(false, std::sync::atomic::Ordering::Relaxed);
            viewport_h = desired;
            last_size = (term_w, term_h);
        }
        // 2) Commit the settled prefix into the terminal's own scrollback. The
        //    intro/onboarding splashes paint full-screen over the cells, so hold
        //    off committing until they're dismissed (then Header/Help flush to
        //    the top of the scrollback as the conversation begins).
        let splash = app.intro_ticks.is_some() || app.onboarding.is_some();
        let k = if splash { 0 } else { settled_prefix_len(&app) };
        if k > 0 {
            let drained: Vec<Cell> = app.cells.drain(0..k).collect();
            commit_cells(&mut terminal, &app, drained, cwidth)?;
        }
        // 3) Draw the live viewport.
        terminal.draw(|f| render::draw(f, &mut app))?;
        // Only run the animation tick while something is actually animating —
        // when idle the loop sleeps and wakes only on events, so we don't
        // rebuild the whole history pane 12×/second for nothing.
        let animating = app.running || app.intro_ticks.is_some() || app.login_in_flight;
        tokio::select! {
            _ = tick.tick(), if animating => {
                app.spinner_idx = app.spinner_idx.wrapping_add(1);
                if let Some(t) = app.intro_ticks.as_mut() {
                    *t += 1;
                    if *t > render::INTRO_LIFE {
                        app.intro_ticks = None;
                    }
                }
            }
            ev = ev_rx.recv() => {
                if let Some(ev) = ev { handle_agent_event(&mut app, ev); }
            }
            ev = ui_rx.recv() => {
                if let Some(ev) = ev { handle_ui_event(&mut app, ev, &cmd_tx); }
            }
            key = key_rx.recv() => {
                if let Some(ev) = key {
                    handle_terminal_event(&mut app, ev, &cmd_tx, &current_cancel);
                }
            }
        }
        if app.quit {
            break Ok(());
        }
    };

    let _ = cmd_tx.send(WorkerCmd::Shutdown);
    let _ = tokio::time::timeout(Duration::from_secs(2), worker).await;

    // Flush whatever is still live (e.g. an interrupted turn) into scrollback so
    // the full transcript survives after exit, then drop the viewport region.
    let term_w = crossterm::terminal::size().map(|(w, _)| w).unwrap_or(80);
    let cwidth = term_w.saturating_sub(2).max(20) as usize;
    let leftover: Vec<Cell> = app.cells.drain(..).collect();
    let _ = commit_cells(&mut terminal, &app, leftover, cwidth);
    let _ = terminal.clear();
    disable_raw_mode()?;
    let _ = execute!(terminal.backend_mut(), DisableBracketedPaste);
    terminal.show_cursor()?;
    result
}

/// A cloud model needs auth: pop a y/n modal offering Lucid Cloud sign-in
/// (no key, billed from the user's plan) with a fall-back to pasting an
/// OpenRouter key. Used both when a turn fails for lack of a key and when the
/// user switches to a cloud model without one.
fn prompt_login_or_key(app: &mut App, model: String) {
    app.approval = Some(PendingApproval {
        kind: ApprovalKind::LoginOrKey { model },
        command: "sign in for cloud models, or bring your own OpenRouter key".into(),
        risk_label: "sign in".into(),
        reason: String::new(),
        request_id: None,
    });
}

fn handle_agent_event(app: &mut App, ev: AgentEvent) {
    match ev {
        AgentEvent::AssistantDelta { text } => app.append_assistant(&text),
        AgentEvent::ReasoningDelta { text } => app.append_reasoning(&text),
        AgentEvent::AssistantDone => {
            app.assistant_streaming = false;
            app.finish_reasoning();
        }
        AgentEvent::ToolBegin { call_id, command, description, background } => {
            app.assistant_streaming = false;
            app.finish_reasoning();
            app.add_cell(Cell::Exec {
                call_id,
                command: render::sanitize_display(&command),
                description: render::sanitize_display(&description),
                output_tail: String::new(),
                status: ExecStatus::Running,
                duration_ms: 0,
                background,
            });
        }
        AgentEvent::ToolEnd { call_id, output, exit_code, duration_ms, success } => {
            let mut handled = false;
            for cell in app.cells.iter_mut().rev() {
                if let Cell::Exec { call_id: cid, output_tail, status, duration_ms: dm, .. } = cell {
                    if *cid == call_id {
                        let tail: Vec<&str> = output.lines().rev().take(10).collect();
                        *output_tail = tail.into_iter().rev().collect::<Vec<_>>().join("\n");
                        *status = if success { ExecStatus::Ok } else { ExecStatus::Failed(exit_code) };
                        *dm = duration_ms;
                        handled = true;
                        break;
                    }
                }
            }
            if !handled {
                app.push_notice(format!("tool finished (exit {exit_code})"));
            }
        }
        AgentEvent::ApprovalRequest { request_id, command, risk_label, reason } => {
            app.approval = Some(PendingApproval {
                kind: ApprovalKind::Tool,
                command,
                risk_label,
                reason,
                request_id: Some(request_id),
            });
        }
        AgentEvent::Notice { text } => app.push_notice(text),
        AgentEvent::Usage { prompt_tokens, session_input, session_output } => {
            app.tokens_used = prompt_tokens;
            app.session_input = session_input;
            app.session_output = session_output;
        }
        AgentEvent::Plan { steps } => {
            // Replace the most recent plan cell so the checklist updates in place.
            for cell in app.cells.iter_mut().rev() {
                if let Cell::Plan(existing) = cell {
                    *existing = steps;
                    return;
                }
            }
            app.add_cell(Cell::Plan(steps));
        }
        AgentEvent::SubagentStatus { id, task, state } => {
            app.push_notice(format!(
                "explorer {}: {state} — {}",
                id + 1,
                render::sanitize_display(&task)
            ));
        }
        AgentEvent::FileEdit { path, op, added, removed, lines } => {
            app.assistant_streaming = false;
            app.finish_reasoning();
            app.add_cell(Cell::Diff { path, op, added, removed, lines });
        }
        AgentEvent::ModelUnavailable { model, needs_key } => {
            let rec = crate::hardware::recommended_local(&app.hardware);
            let free = app.hardware.free_ram_gb;
            let local_tip = match rec {
                Some(m) => {
                    let mem = if free > 0.0 { format!("{free:.0} GB free") } else { "enough memory".into() };
                    format!("this machine has {mem} — download a local model: /download {0} then /model {0}", m.tag)
                }
                None => "this machine can't run a capable model locally — use a cloud model with an OpenRouter key.".into(),
            };
            if needs_key {
                // Offer sign-in first (cloud models, no key); the modal's "n"
                // falls back to pasting an OpenRouter key.
                prompt_login_or_key(app, model);
            } else {
                app.add_cell(Cell::Error(format!(
                    "model '{model}' was not found at the provider (404). {local_tip}"
                )));
                app.push_notice("download local: /download qwen3:4b  •  list models: /models");
            }
        }
        AgentEvent::CodexReauth { reason } => {
            app.assistant_streaming = false;
            app.finish_reasoning();
            // Come back to the same Codex model once the session is restored.
            if app.pending_codex_model.is_none() {
                app.pending_codex_model = Some(app.cfg.model.clone());
            }
            app.add_cell(Cell::Notice(format!(
                "{reason} — restoring your ChatGPT session (no API key needed)…"
            )));
            // force = true: the access token is known-dead, so refresh it even
            // if it hasn't hit its `exp` yet; falls back to browser sign-in.
            start_codex_login(app, true);
        }
        AgentEvent::TurnComplete => {
            app.running = false;
            app.assistant_streaming = false;
            app.finish_reasoning();
            // Plan mode just produced a plan — offer to implement it.
            if app.cfg.mode == crate::config::Mode::Plan
                && app.approval.is_none()
                && matches!(app.cells.last(), Some(Cell::Assistant(_)))
            {
                app.approval = Some(PendingApproval {
                    kind: ApprovalKind::ImplementPlan,
                    command: "Implement this plan? Switches to agent mode and executes it.".into(),
                    risk_label: "plan ready".into(),
                    reason: String::new(),
                    request_id: None,
                });
            }
        }
        AgentEvent::Error { message } => {
            app.add_cell(Cell::Error(render::sanitize_display(&message)));
        }
    }
}

fn handle_ui_event(app: &mut App, ev: UiEvent, cmd_tx: &mpsc::UnboundedSender<WorkerCmd>) {
    match ev {
        UiEvent::HardwareDetected(hw) => {
            app.hardware = hw;
            app.hardware_detected = true;
        }
        UiEvent::OllamaTags(tags) => {
            app.ollama_available = true;
            app.ollama_tags = tags;
        }
        UiEvent::OllamaMissing => {
            app.ollama_available = false;
        }
        UiEvent::DownloadProgress { tag, line } => {
            for cell in app.cells.iter_mut().rev() {
                if let Cell::Download { tag: t, line: l, done, .. } = cell {
                    if *t == tag && !*done {
                        *l = line;
                        return;
                    }
                }
            }
        }
        UiEvent::DownloadDone { tag, ok } => {
            for cell in app.cells.iter_mut().rev() {
                if let Cell::Download { tag: t, done, ok: o, line } = cell {
                    if *t == tag && !*done {
                        *done = true;
                        *o = ok;
                        if ok {
                            *line = "download complete".into();
                        }
                        break;
                    }
                }
            }
            if ok {
                if !app.ollama_tags.contains(&tag) {
                    app.ollama_tags.push(tag.clone());
                }
                // Onboarding "run locally" path: switch to the model now that
                // it's downloaded, so the user lands ready to chat.
                if app.pending_auto_model.as_deref() == Some(tag.as_str()) {
                    app.pending_auto_model = None;
                    switch_model(app, &tag, cmd_tx);
                } else {
                    app.push_notice(format!("{tag} ready — switch with /model {tag}"));
                }
            }
        }
        UiEvent::OllamaInstalled { ok } => {
            app.ollama_installing = false;
            for cell in app.cells.iter_mut().rev() {
                if let Cell::Download { tag, done, ok: o, line } = cell {
                    if tag == "ollama" && !*done {
                        *done = true;
                        *o = ok;
                        *line = if ok { "Ollama installed and running".into() } else { line.clone() };
                        break;
                    }
                }
            }
            if ok {
                app.ollama_available = true;
                app.push_notice("Ollama is ready");
                tokio::spawn(fetch_ollama_tags(app.ui_tx.clone()));
                if let Some(tag) = app.pending_download.take() {
                    app.add_cell(Cell::Download { tag: tag.clone(), line: "starting download…".into(), done: false, ok: false });
                    spawn_download(app, tag);
                }
            } else {
                app.pending_download = None;
                app.push_notice("Ollama install failed — see output above, or install manually from ollama.com/download");
            }
        }
        UiEvent::SkillsResult(msg) => app.push_notice(msg),
        UiEvent::DeleteDone { tag, ok, message } => {
            if ok {
                app.ollama_tags
                    .retain(|t| t != &tag && t.trim_end_matches(":latest") != tag);
            }
            app.push_notice(message);
        }
        UiEvent::LoginResult { ok, message, token, email } => {
            app.login_in_flight = false;
            if ok {
                app.cfg.lucid_token = token.clone();
                app.cfg.lucid_email = email.clone();
                app.cfg.provider = Provider::LucidCloud;
                app.cfg.base_url = Provider::LucidCloud.default_base_url();
                app.cfg.api_key = token.clone();
                app.cfg.onboarded = true;
                // If the active model isn't one Lucid Cloud serves, land on the
                // default ("Lucid's choice") so the next message just works.
                let served = matches!(
                    app.cfg.model.as_str(),
                    "tencent/hy3:free" | "deepseek/deepseek-v4-pro" | "z-ai/glm-5.2" | "z-ai/glm-5.1" | "moonshotai/kimi-k2.7-code"
                );
                if !served {
                    app.cfg.model = "tencent/hy3:free".into();
                    app.cfg.model_alias = "hy3".into();
                    app.cfg.max_context_tokens = crate::config::CTX_256K;
                }
                let _ = save_config_values(&[
                    ("provider", Provider::LucidCloud.label()),
                    ("model", app.cfg.model.as_str()),
                    ("lucid_token", token.as_str()),
                    ("lucid_email", email.as_str()),
                    ("onboarded", "true"),
                ]);
                let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
            }
            app.push_notice(message);
        }
        UiEvent::CodexLoginResult { ok, message, account } => {
            app.login_in_flight = false;
            if ok {
                // Switch to the Codex provider AND select a model so the user
                // lands ready to chat (the prior model is an OpenRouter id the
                // Codex endpoint wouldn't accept). Use the model they asked for
                // when login was auto-triggered, else the flagship default.
                let target = app.pending_codex_model.take().unwrap_or_else(|| "gpt-5.5".into());
                app.cfg.provider = Provider::Codex;
                app.cfg.base_url = Provider::Codex.default_base_url();
                app.cfg.api_key = "codex".into();
                apply_model(&mut app.cfg, &target, true, true);
                app.cfg.onboarded = true;
                let _ = save_config_values(&[
                    ("provider", Provider::Codex.label()),
                    ("model", app.cfg.model.as_str()),
                    ("onboarded", "true"),
                ]);
                let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                app.push_notice(format!(
                    "{message} — signed in as {account}. Using {} — switch with /model gpt-5.4, gpt-5.4-mini…",
                    app.cfg.model_alias
                ));
            } else {
                // Login failed — drop the pending model and reopen the wizard so
                // the user can pick another path (OpenRouter key / local model).
                app.pending_codex_model = None;
                app.onboarding = Some(Onboard { selected: 0 });
                app.push_notice(message);
            }
        }
        UiEvent::CodexLoginUrl(url) => {
            app.push_notice(format!("if your browser didn't open, sign in here:\n{url}"));
        }
        UiEvent::CreditsResult(msg) => app.push_notice(msg),
        UiEvent::UpdateAvailable { latest } => {
            // Don't re-nag a version the user already chose to skip.
            if latest == app.cfg.skipped_version || app.approval.is_some() {
                return;
            }
            let (_, command) = crate::update::update_command();
            app.approval = Some(PendingApproval {
                kind: ApprovalKind::Update { latest: latest.clone(), command },
                command: format!("Update to v{latest}? (you have v{})", crate::update::CURRENT),
                risk_label: "update".into(),
                reason: String::new(),
                request_id: None,
            });
        }
        UiEvent::OpenRouterModelsRefreshed => {
            // Cache now holds exact windows. If the current model isn't a curated
            // preset, re-resolve its context limit (cache → heuristic) and push
            // the corrected value to the worker so compaction tracks it.
            if crate::config::find_preset(&app.cfg.model).is_none()
                && app.cfg.provider != Provider::Ollama
            {
                let before = app.cfg.max_context_tokens;
                let resolved = crate::config::resolve_context_tokens(&app.cfg.model);
                if resolved != before {
                    app.cfg.max_context_tokens = resolved;
                    let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                }
            }
        }
    }
}

/// Which of a child's pipes to stream in the foreground; the other is drained.
enum Stream {
    Stdout,
    Stderr,
}

/// Spawn `cmd` with stdin closed and both pipes captured, stream the chosen
/// `foreground` pipe line-by-line through `on_line`, and drain the other pipe to
/// EOF in a background task. The drain task is awaited after the child exits, so
/// a runaway child can never leave a busy-spinning detached task behind. Returns
/// whether the child exited successfully; `Err` only when the process failed to
/// spawn, so callers can surface their own "failed to start" message.
///
/// This is the single home of three fixes the three spawn sites used to repeat:
/// stdin is closed (a child prompting for sudo/confirmation under the raw-mode
/// TUI gets EOF instead of blocking on invisible input); the non-foreground pipe
/// is drained concurrently (a child writing past the OS pipe buffer — 64KB on
/// macOS — to an unread pipe would otherwise block forever); and the drain loop
/// uses `while let Ok(Some(_))`, which terminates at EOF (a bare `while ….is_ok()`
/// busy-spins once the pipe closes).
async fn stream_child<F: FnMut(&str)>(
    mut cmd: tokio::process::Command,
    foreground: Stream,
    mut on_line: F,
) -> std::io::Result<bool> {
    use tokio::io::{AsyncBufReadExt, BufReader};
    let mut child = cmd
        .stdin(std::process::Stdio::null())
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped())
        .spawn()?;

    // Drain the non-foreground pipe in the background, concurrently with the
    // foreground read below, then await it after the child exits.
    let drain = match foreground {
        Stream::Stdout => child.stderr.take().map(|s| {
            tokio::spawn(async move {
                let mut lines = BufReader::new(s).lines();
                while let Ok(Some(_)) = lines.next_line().await {}
            })
        }),
        Stream::Stderr => child.stdout.take().map(|s| {
            tokio::spawn(async move {
                let mut lines = BufReader::new(s).lines();
                while let Ok(Some(_)) = lines.next_line().await {}
            })
        }),
    };

    match foreground {
        Stream::Stdout => {
            if let Some(stdout) = child.stdout.take() {
                let mut lines = BufReader::new(stdout).lines();
                while let Ok(Some(line)) = lines.next_line().await {
                    on_line(&line);
                }
            }
        }
        Stream::Stderr => {
            if let Some(stderr) = child.stderr.take() {
                let mut lines = BufReader::new(stderr).lines();
                while let Ok(Some(line)) = lines.next_line().await {
                    on_line(&line);
                }
            }
        }
    }

    let ok = child.wait().await.map(|s| s.success()).unwrap_or(false);
    if let Some(drain) = drain {
        let _ = drain.await;
    }
    Ok(ok)
}

/// Install Ollama itself so the user never has to leave the TUI:
/// macOS → Homebrew, Linux → official install script, Windows → winget.
fn spawn_install_ollama(app: &App) {
    let ui_tx = app.ui_tx.clone();
    tokio::spawn(async move {
        let (cmd, args): (&str, Vec<&str>) = match std::env::consts::OS {
            "macos" => ("brew", vec!["install", "ollama"]),
            "linux" => ("sh", vec!["-c", "curl -fsSL https://ollama.com/install.sh | sh"]),
            "windows" => (
                "winget",
                vec!["install", "-e", "--id", "Ollama.Ollama", "--accept-source-agreements", "--accept-package-agreements"],
            ),
            other => {
                let _ = ui_tx.send(UiEvent::DownloadProgress { tag: "ollama".into(), line: format!("unsupported OS: {other}") });
                let _ = ui_tx.send(UiEvent::OllamaInstalled { ok: false });
                return;
            }
        };
        let mut command = tokio::process::Command::new(cmd);
        command.args(&args);
        let progress = ui_tx.clone();
        let ok = match stream_child(command, Stream::Stdout, |line| {
            let clean = line.trim();
            if !clean.is_empty() {
                let _ = progress.send(UiEvent::DownloadProgress { tag: "ollama".into(), line: clean.to_string() });
            }
        })
        .await
        {
            Ok(ok) => ok,
            Err(e) => {
                let _ = ui_tx.send(UiEvent::DownloadProgress {
                    tag: "ollama".into(),
                    line: format!("installer failed to start ({cmd}): {e} — install manually from ollama.com/download"),
                });
                let _ = ui_tx.send(UiEvent::OllamaInstalled { ok: false });
                return;
            }
        };
        if ok {
            // Start the server detached (memory-tuned) and give it a moment.
            spawn_ollama_serve();
            tokio::time::sleep(std::time::Duration::from_secs(2)).await;
        }
        let _ = ui_tx.send(UiEvent::OllamaInstalled { ok });
    });
}

/// Run the skills.sh CLI (`npx skills …`) and stream its output.
fn spawn_skills_cli(app: &App, args: Vec<String>, label: String) {
    let ui_tx = app.ui_tx.clone();
    tokio::spawn(async move {
        let mut command = tokio::process::Command::new("npx");
        command.arg("-y").arg("skills").args(&args);
        let progress = ui_tx.clone();
        let progress_label = label.clone();
        let ok = match stream_child(command, Stream::Stdout, |line| {
            let clean = line.trim();
            if !clean.is_empty() {
                let _ = progress.send(UiEvent::DownloadProgress { tag: progress_label.clone(), line: clean.to_string() });
            }
        })
        .await
        {
            Ok(ok) => ok,
            Err(e) => {
                let _ = ui_tx.send(UiEvent::SkillsResult(format!(
                    "could not run npx ({e}) — Node.js is required for skills.sh installs"
                )));
                return;
            }
        };
        let _ = ui_tx.send(UiEvent::DownloadDone { tag: label.clone(), ok });
        if ok {
            let _ = ui_tx.send(UiEvent::SkillsResult(
                "skill installed — it is available to the agent from your next message ( /skills to list )".into(),
            ));
        }
    });
}

/// Query the skills.sh registry search API.
fn spawn_skills_search(app: &App, query: String) {
    let ui_tx = app.ui_tx.clone();
    tokio::spawn(async move {
        let url = format!(
            "https://www.skills.sh/api/search?q={}",
            query.replace(' ', "+")
        );
        let client = reqwest::Client::new();
        let msg = match client
            .get(&url)
            .header("User-Agent", "lucid-train")
            .timeout(std::time::Duration::from_secs(10))
            .send()
            .await
        {
            Ok(resp) => match resp.json::<serde_json::Value>().await {
                Ok(v) => {
                    let skills = v["skills"].as_array().cloned().unwrap_or_default();
                    if skills.is_empty() {
                        format!("no skills found on skills.sh for '{query}'")
                    } else {
                        let mut s = format!("skills.sh results for '{query}':\n");
                        for sk in skills.iter().take(8) {
                            let source = sk["source"].as_str().unwrap_or("");
                            let id = sk["skillId"].as_str().unwrap_or("");
                            let installs = sk["installs"].as_u64().unwrap_or(0);
                            s.push_str(&format!("  {source} — {id} ({installs} installs)\n"));
                        }
                        s.push_str("install with: /skills install <owner/repo> [skill-name]");
                        s
                    }
                }
                Err(e) => format!("skills.sh search failed: {e}"),
            },
            Err(e) => format!("skills.sh search failed: {e}"),
        };
        let _ = ui_tx.send(UiEvent::SkillsResult(msg));
    });
}

/// Run the best-guess upgrade command in the background.
fn spawn_update(app: &App, latest: String, command: String) {
    let ui_tx = app.ui_tx.clone();
    tokio::spawn(async move {
        let out = tokio::process::Command::new("sh")
            .arg("-c")
            .arg(&command)
            .env("LUCID_NO_LAUNCH", "1") // installer must not relaunch the TUI mid-update
            .output()
            .await;
        let msg = match out {
            // The installer upgrades one install location (usually ~/.local/bin
            // via uv). If a *different* copy shadows it on PATH — or the running
            // binary is that older shadow — the next launch would still be old.
            // Deshadow: copy the freshly-installed vLATEST over the running exe
            // and any older PATH copies so the update actually takes effect.
            Ok(o) if o.status.success() => {
                tokio::task::spawn_blocking(move || deshadow_to(&latest))
                    .await
                    .unwrap_or_else(|_| "update installed — restart lucid-train.".to_string())
            }
            Ok(o) => format!(
                "update command failed: {}. Run manually: {command}",
                String::from_utf8_lossy(&o.stderr).lines().last().unwrap_or("").trim()
            ),
            Err(e) => format!("could not run update: {e}. Run manually: {command}"),
        };
        let _ = ui_tx.send(UiEvent::CreditsResult(msg)); // reuse generic notice channel
    });
}

/// After the installer places vLATEST at one location, propagate it to every
/// other `lucid-train` on PATH (including the running exe) that's still older,
/// so a shadowing copy can't keep the old version live. Best-effort: unwritable
/// or missing paths are skipped. macOS binaries are re-signed or the kernel
/// SIGKILLs them (Code Signature Invalid).
fn deshadow_to(latest: &str) -> String {
    let exe_name = if cfg!(windows) { "lucid-train.exe" } else { "lucid-train" };
    let mut candidates: Vec<std::path::PathBuf> = Vec::new();
    if let Ok(path) = std::env::var("PATH") {
        candidates.extend(std::env::split_paths(&path).map(|d| d.join(exe_name)));
    }
    if let Ok(home) = std::env::var("HOME") {
        for d in [".local/bin", ".cargo/bin"] {
            candidates.push(std::path::Path::new(&home).join(d).join(exe_name));
        }
    }
    for d in ["/opt/homebrew/bin", "/usr/local/bin"] {
        candidates.push(std::path::Path::new(d).join(exe_name));
    }
    if let Ok(run) = std::env::current_exe() {
        candidates.push(run);
    }

    let version_of = |p: &std::path::Path| -> Option<String> {
        let o = std::process::Command::new(p).arg("--version").output().ok()?;
        Some(String::from_utf8_lossy(&o.stdout).split_whitespace().last()?.trim().to_string())
    };

    // The freshly-installed source (first copy already reporting vLATEST).
    let source = candidates
        .iter()
        .find(|p| p.exists() && version_of(p).as_deref() == Some(latest))
        .cloned();
    let Some(source) = source else {
        return format!(
            "update installed v{latest}, but the new binary isn't resolvable on PATH yet — \
             restart your shell (or ensure ~/.local/bin is early on PATH), then relaunch."
        );
    };

    let src_canon = std::fs::canonicalize(&source).ok();
    let mut refreshed = 0usize;
    let mut seen = std::collections::HashSet::new();
    for target in &candidates {
        let Ok(canon) = std::fs::canonicalize(target) else { continue };
        if Some(&canon) == src_canon.as_ref() || !seen.insert(canon) {
            continue; // same file as source, or already handled
        }
        if version_of(target).as_deref() == Some(latest) {
            continue; // already up to date
        }
        if replace_binary(&source, target).is_ok() {
            refreshed += 1;
        }
    }
    if refreshed > 0 {
        format!(
            "updated to v{latest} — refreshed {refreshed} cop{} on PATH. Restart lucid-train.",
            if refreshed == 1 { "y" } else { "ies" }
        )
    } else {
        format!("updated to v{latest} at {} — restart lucid-train.", source.display())
    }
}

/// Atomically replace the binary at `dst` with the bytes of `src` (copy to a
/// sibling temp, re-sign on macOS, then rename over — which works even while the
/// old file is executing, since Unix keeps the running inode alive).
fn replace_binary(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
    let tmp = dst.with_extension("lucid-update-tmp");
    std::fs::copy(src, &tmp)?;
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755));
    }
    #[cfg(target_os = "macos")]
    {
        let _ = std::process::Command::new("codesign").args(["-s", "-", "-f"]).arg(&tmp).output();
    }
    std::fs::rename(&tmp, dst).map_err(|e| {
        let _ = std::fs::remove_file(&tmp);
        e
    })
}

/// Delete a local model via `ollama rm`.
fn spawn_delete(app: &App, tag: String) {
    let ui_tx = app.ui_tx.clone();
    tokio::spawn(async move {
        let out = tokio::process::Command::new("ollama")
            .args(["rm", &tag])
            .output()
            .await;
        let (ok, message) = match out {
            Ok(o) if o.status.success() => (true, format!("deleted local model {tag} (disk space freed)")),
            Ok(o) => (
                false,
                format!(
                    "delete failed: {}",
                    String::from_utf8_lossy(&o.stderr).trim()
                ),
            ),
            Err(e) => (false, format!("delete failed: {e}")),
        };
        let _ = ui_tx.send(UiEvent::DeleteDone { tag, ok, message });
    });
}

/// Pull a model through Ollama, streaming progress lines into the UI.
fn spawn_download(app: &App, tag: String) {
    let ui_tx = app.ui_tx.clone();
    tokio::spawn(async move {
        // ollama writes progress to stderr, so stream stderr and drain stdout.
        let mut command = tokio::process::Command::new("ollama");
        command.args(["pull", &tag]);
        let progress = ui_tx.clone();
        let progress_tag = tag.clone();
        let ok = match stream_child(command, Stream::Stderr, |line| {
            let clean = line
                .replace(['\r'], " ")
                .split("\u{1b}[")
                .next()
                .unwrap_or("")
                .trim()
                .to_string();
            if !clean.is_empty() {
                let _ = progress.send(UiEvent::DownloadProgress { tag: progress_tag.clone(), line: clean });
            }
        })
        .await
        {
            Ok(ok) => ok,
            Err(e) => {
                let _ = ui_tx.send(UiEvent::DownloadProgress { tag: tag.clone(), line: format!("failed to start ollama: {e}") });
                let _ = ui_tx.send(UiEvent::DownloadDone { tag, ok: false });
                return;
            }
        };
        let _ = ui_tx.send(UiEvent::DownloadDone { tag, ok });
    });
}

/// Log in to Lucid Cloud with Google via the loopback browser flow.
fn spawn_login(app: &App) {
    let ui_tx = app.ui_tx.clone();
    let base = Provider::LucidCloud.default_base_url();
    tokio::spawn(async move {
        let http = reqwest::Client::new();
        match crate::lucid_auth::login(&http, &base).await {
            Ok(info) => {
                let plan = if info.status.is_empty() || info.status == "none" {
                    "no active plan — subscribe with /credits subscribe <starter|pro|max>".to_string()
                } else {
                    format!("{} plan ({}) • ${:.2} credit", info.tier, info.status, info.balance_usd)
                };
                let msg = format!("signed in as {} — {plan}", info.email);
                let _ = ui_tx.send(UiEvent::LoginResult { ok: true, message: msg, token: info.token, email: info.email });
            }
            Err(e) => {
                let _ = ui_tx.send(UiEvent::LoginResult {
                    ok: false,
                    message: format!("login failed: {e:#}. Is the Lucid Cloud backend running? (set LUCID_CLOUD_URL, default {base})"),
                    token: String::new(),
                    email: String::new(),
                });
            }
        }
    });
}

/// Sign out of Lucid Cloud: revoke the token server-side (best effort).
fn spawn_logout(app: &App) {
    let ui_tx = app.ui_tx.clone();
    let token = app.cfg.lucid_token.clone();
    let base = Provider::LucidCloud.default_base_url();
    let root = crate::lucid_auth::cloud_root(&base);
    tokio::spawn(async move {
        if !token.is_empty() {
            let client = reqwest::Client::new();
            let _ = client
                .post(format!("{root}/auth/logout"))
                .bearer_auth(&token)
                .timeout(std::time::Duration::from_secs(10))
                .send()
                .await;
        }
        let _ = ui_tx.send(UiEvent::CreditsResult("signed out of Lucid Cloud".into()));
    });
}

/// Navigate / select in the first-run onboarding menu.
fn handle_onboarding_key(app: &mut App, key: KeyEvent, cmd_tx: &mpsc::UnboundedSender<WorkerCmd>) {
    let n = ONBOARD_OPTIONS.len();
    let Some(ob) = app.onboarding.as_mut() else { return };
    match key.code {
        KeyCode::Up | KeyCode::Char('k') => {
            ob.selected = (ob.selected + n - 1) % n;
        }
        KeyCode::Down | KeyCode::Char('j') | KeyCode::Tab => {
            ob.selected = (ob.selected + 1) % n;
        }
        KeyCode::Char(c @ '1'..='4') => {
            let idx = (c as usize) - ('1' as usize);
            if idx < n {
                onboarding_select(app, idx, cmd_tx);
            }
        }
        KeyCode::Enter => {
            let idx = ob.selected;
            onboarding_select(app, idx, cmd_tx);
        }
        KeyCode::Esc => onboarding_select(app, n - 1, cmd_tx), // last = "explore"
        _ => {}
    }
}

/// Act on an onboarding choice, routing into the existing flow for each.
fn onboarding_select(app: &mut App, idx: usize, cmd_tx: &mpsc::UnboundedSender<WorkerCmd>) {
    app.onboarding = None; // dismiss the wizard; the chosen flow takes over
    // Remember that the wizard was seen so it won't reappear every launch (even
    // if the user skipped). Re-open it anytime with /onboarding.
    if !app.cfg.onboarded {
        app.cfg.onboarded = true;
        let _ = save_config_values(&[("onboarded", "true")]);
    }
    match idx {
        // Connect ChatGPT / Codex — import an existing login or start OAuth.
        0 => {
            app.push_notice(
                "connecting ChatGPT — importing an existing Codex login or opening your browser to sign in (uses the Codex subscription endpoint, undocumented and subject to OpenAI's terms)…",
            );
            start_codex_login(app, false);
        }
        // Bring an OpenRouter key — masked prompt, then auto-select a model.
        1 => {
            let rec = crate::hardware::recommended_cloud().alias.to_string();
            app.pending_input = Some(PendingInput {
                label: "OpenRouter API key".into(),
                hint: "paste your key (get one at openrouter.ai/keys) — Enter to save, Esc to cancel".into(),
                masked: true,
                action: InputAction::ApiKey { model: Some(rec) },
            });
        }
        // Run a model locally — download a fitting model, auto-switch when ready.
        2 => {
            let tag = crate::hardware::recommended_local(&app.hardware)
                .map(|m| m.tag.to_string())
                .unwrap_or_else(|| "qwen3:4b".to_string());
            app.pending_auto_model = Some(tag.clone());
            app.push_notice(format!(
                "setting up {tag} locally — this downloads the model (and installs Ollama if needed), then switches to it automatically."
            ));
            handle_slash_command(app, &format!("download {tag}"), cmd_tx);
        }
        // Explore — just reveal the normal session.
        _ => {
            app.push_notice("you're all set — type a task, or run /setup, /login, /model, /help anytime.");
        }
    }
}

/// Import (and refresh) an existing Codex login, or run the "Sign in with
/// ChatGPT" browser flow, off the UI thread. Reports back via
/// `UiEvent::CodexLoginResult`.
///
/// `force` re-refreshes the on-disk token even when it hasn't visibly expired —
/// used for recovery after a 401, where the access token is known-dead. When
/// `false` (first-time import / `/login`) a still-valid token is adopted as-is
/// and only an expired one is refreshed.
/// Guarded entry point for the Codex browser login. Shows a clear waiting state
/// and ignores re-triggers while a flow is already running — a second
/// `codex_auth::login()` would fail to re-bind `localhost:1455` and trap the user
/// until restart. Always go through this instead of calling `spawn_codex_login`.
fn start_codex_login(app: &mut App, force: bool) {
    if app.login_in_flight {
        app.push_notice("already signing in to ChatGPT — finish it in your browser (the sign-in URL is shown above).");
        return;
    }
    app.login_in_flight = true;
    spawn_codex_login(app, force);
}

fn spawn_codex_login(app: &App, force: bool) {
    let ui_tx = app.ui_tx.clone();
    tokio::spawn(async move {
        let http = reqwest::Client::new();
        // 1. Reuse/refresh an existing on-disk Codex login if one is usable.
        if let Ok(tokens) = crate::codex_auth::ensure_valid(&http, force).await {
            let _ = ui_tx.send(UiEvent::CodexLoginResult {
                ok: true,
                message: if force {
                    "refreshed your ChatGPT session".into()
                } else {
                    "imported your existing Codex login".into()
                },
                account: crate::codex_auth::account_label(&tokens),
            });
            return;
        }
        // 2. No usable login on disk — run the OAuth PKCE flow (opens a browser).
        // Surface the authorize URL up front so the user can complete sign-in even
        // if `open_browser` silently fails.
        let url_tx = ui_tx.clone();
        let on_url = move |url: &str| {
            let _ = url_tx.send(UiEvent::CodexLoginUrl(url.to_string()));
        };
        match crate::codex_auth::login(&http, on_url).await {
            Ok(tokens) => {
                let _ = ui_tx.send(UiEvent::CodexLoginResult {
                    ok: true,
                    message: "signed in with ChatGPT".into(),
                    account: crate::codex_auth::account_label(&tokens),
                });
            }
            Err(e) => {
                let _ = ui_tx.send(UiEvent::CodexLoginResult {
                    ok: false,
                    message: format!("Codex login failed: {e}"),
                    account: String::new(),
                });
            }
        }
    });
}

/// Query balance or create a Stripe checkout link.
#[allow(dead_code)]
fn spawn_credits(app: &App, action: String) {
    let ui_tx = app.ui_tx.clone();
    let token = app.cfg.lucid_token.clone();
    let base = Provider::LucidCloud.default_base_url();
    let root = crate::lucid_auth::cloud_root(&base);
    tokio::spawn(async move {
        if token.is_empty() {
            let _ = ui_tx.send(UiEvent::CreditsResult("not signed in — run /login first".into()));
            return;
        }
        let client = reqwest::Client::new();
        let msg = if let Some(tier) = action.strip_prefix("subscribe ").map(|s| s.trim().to_lowercase()) {
            if !matches!(tier.as_str(), "starter" | "pro" | "max") {
                "usage: /credits subscribe <starter|pro|max>".to_string()
            } else {
                match client
                    .post(format!("{root}/billing/subscribe"))
                    .bearer_auth(&token)
                    .json(&serde_json::json!({ "tier": tier }))
                    .timeout(std::time::Duration::from_secs(20))
                    .send()
                    .await
                {
                    Ok(r) if r.status().is_success() => {
                        let v: serde_json::Value = r.json().await.unwrap_or_default();
                        match v["checkout_url"].as_str() {
                            Some(url) => format!("complete your {tier} subscription in the browser: {url}"),
                            None => format!("unexpected response: {v}"),
                        }
                    }
                    Ok(r) => format!("subscribe failed: HTTP {}", r.status()),
                    Err(e) => format!("subscribe failed: {e}"),
                }
            }
        } else {
            match crate::lucid_auth::fetch_me(&client, &base, &token).await {
                Ok(me) => {
                    let plan = if me.status.is_empty() || me.status == "none" {
                        "no active plan".to_string()
                    } else {
                        format!("{} ({})", me.tier, me.status)
                    };
                    format!(
                        "plan: {plan} • balance ${:.4} • subscribe with /credits subscribe <starter|pro|max>",
                        me.balance_usd
                    )
                }
                Err(e) => format!("could not fetch account: {e}"),
            }
        };
        let _ = ui_tx.send(UiEvent::CreditsResult(msg));
    });
}

fn handle_terminal_event(
    app: &mut App,
    ev: Event,
    cmd_tx: &mpsc::UnboundedSender<WorkerCmd>,
    cancel: &Arc<Mutex<CancellationToken>>,
) {
    match ev {
        Event::Key(key) if key.kind != KeyEventKind::Release => {
            // ---- intro animation: any key skips it (typed chars carry over) ----
            if app.intro_ticks.is_some() {
                app.intro_ticks = None;
                // When onboarding is queued behind the intro, swallow the key so
                // it doesn't leak into the (hidden) composer — the menu appears.
                if app.onboarding.is_none() {
                    if let KeyCode::Char(c) = key.code {
                        if !key.modifiers.contains(KeyModifiers::CONTROL) {
                            app.input.push(c);
                            app.cursor = app.input.len();
                            app.refresh_suggestions();
                        }
                    }
                }
                return;
            }
            // ---- onboarding wizard intercepts keys (menu navigation) ----
            if app.onboarding.is_some() {
                handle_onboarding_key(app, key, cmd_tx);
                return;
            }
            // ---- inline input prompt (e.g. API key) intercepts keys ----
            if app.pending_input.is_some() {
                match key.code {
                    KeyCode::Enter => finish_input(app, cmd_tx),
                    KeyCode::Esc => {
                        app.pending_input = None;
                        app.input.clear();
                        app.cursor = 0;
                        app.push_notice("input cancelled — /model to switch, or /download for a local model");
                    }
                    KeyCode::Backspace => {
                        if app.cursor > 0 {
                            let prev = floor_char_boundary(&app.input, app.cursor - 1);
                            app.input.replace_range(prev..app.cursor, "");
                            app.cursor = prev;
                        }
                    }
                    KeyCode::Char('u') if key.modifiers.contains(KeyModifiers::CONTROL) => {
                        app.input.clear();
                        app.cursor = 0;
                    }
                    KeyCode::Char(c) if !key.modifiers.contains(KeyModifiers::CONTROL) => {
                        app.input.insert(app.cursor, c);
                        app.cursor += c.len_utf8();
                    }
                    _ => {}
                }
                return;
            }
            // ---- approval modal intercepts keys ----
            if app.approval.is_some() {
                let decision = match key.code {
                    KeyCode::Char('y') | KeyCode::Enter => Some(ApprovalDecision::Approve),
                    KeyCode::Char('a') => Some(ApprovalDecision::ApproveForSession),
                    KeyCode::Char('n') | KeyCode::Esc => Some(ApprovalDecision::Deny),
                    _ => None,
                };
                if let Some(d) = decision {
                    if let Some(mut pa) = app.approval.take() {
                        match pa.kind {
                            ApprovalKind::Tool => {
                                let verdict = match d {
                                    ApprovalDecision::Approve => "approved",
                                    ApprovalDecision::ApproveForSession => "approved for session",
                                    ApprovalDecision::Deny => "denied",
                                };
                                app.push_notice(format!("{verdict}: {}", pa.command));
                                if let Some(id) = pa.request_id.take() {
                                    let _ = app.approval_registry.respond(&id, d);
                                }
                            }
                            ApprovalKind::FullAuto => {
                                if d == ApprovalDecision::Deny {
                                    app.push_notice("kept current approval policy");
                                } else {
                                    app.cfg.approval = ApprovalPolicy::FullAuto;
                                    let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                                    app.push_notice("approval policy: full-auto — every command auto-runs except hard-blocked ones. /approvals auto to go back.");
                                }
                            }
                            ApprovalKind::ImplementPlan => {
                                if d == ApprovalDecision::Deny {
                                    app.push_notice("staying in plan mode — refine the plan or /agent to switch manually");
                                } else {
                                    // Switch to agent mode and implement the plan.
                                    app.cfg.mode = crate::config::Mode::Agent;
                                    let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                                    app.push_notice("switched to agent mode — implementing the plan");
                                    let msg = "Proceed: implement the plan you proposed above, step by step, verifying as you go.".to_string();
                                    app.add_cell(Cell::User(msg.clone()));
                                    app.running = true;
                                    let _ = cmd_tx.send(WorkerCmd::UserMessage(msg));
                                }
                            }
                            ApprovalKind::Update { latest, command } => {
                                if d == ApprovalDecision::Deny {
                                    // Skip this version — persist so we don't re-nag.
                                    app.cfg.skipped_version = latest.clone();
                                    let _ = save_config_values(&[("skipped_version", latest.as_str())]);
                                    app.push_notice(format!("skipped v{latest} — run /update anytime, or update with: {command}"));
                                } else {
                                    spawn_update(app, latest.clone(), command.clone());
                                    app.push_notice(format!("updating to v{latest} — `{command}` (restart lucid-train when it finishes)"));
                                }
                            }
                            ApprovalKind::LoginOrKey { model } => {
                                if d == ApprovalDecision::Deny {
                                    // Fall back to bringing your own OpenRouter key.
                                    app.pending_input = Some(PendingInput {
                                        label: "OpenRouter API key required".into(),
                                        hint: "paste your key (openrouter.ai/keys) — Enter to save, Esc to cancel".into(),
                                        masked: true,
                                        action: InputAction::ApiKey { model: Some(model) },
                                    });
                                } else {
                                    // Sign in to Lucid Cloud (no key, billed from plan).
                                    spawn_login(app);
                                    app.login_in_flight = true;
                                    app.push_notice("opening your browser to sign in…");
                                }
                            }
                        }
                    }
                }
                return;
            }

            // ---- autocomplete navigation ----
            if !app.suggestions.is_empty() {
                match key.code {
                    KeyCode::Tab => {
                        if let Some(s) = app.suggestions.get(app.sugg_idx) {
                            app.input = s.insert.clone();
                            app.cursor = app.input.len();
                            app.refresh_suggestions();
                        }
                        return;
                    }
                    KeyCode::Enter => {
                        // Enter accepts the highlighted suggestion when it
                        // extends the input; only submits when input already
                        // equals the completed suggestion.
                        if let Some(s) = app.suggestions.get(app.sugg_idx) {
                            if s.insert.trim_end() != app.input.trim_end() {
                                app.input = s.insert.clone();
                                app.cursor = app.input.len();
                                app.refresh_suggestions();
                                return;
                            }
                        }
                        // fall through to submit below
                        submit(app, cmd_tx);
                        return;
                    }
                    // While browsing history (hist_idx set), Up/Down keep walking
                    // history instead of the autocomplete list — otherwise
                    // recalling a slash command repopulates suggestions and traps
                    // navigation on that one entry.
                    KeyCode::Down if app.hist_idx.is_none() => {
                        app.sugg_idx = (app.sugg_idx + 1) % app.suggestions.len();
                        return;
                    }
                    KeyCode::Up if app.hist_idx.is_none() => {
                        app.sugg_idx =
                            (app.sugg_idx + app.suggestions.len() - 1) % app.suggestions.len();
                        return;
                    }
                    _ => {}
                }
            }

            match (key.code, key.modifiers) {
                (KeyCode::Char('c'), KeyModifiers::CONTROL) => {
                    if !app.input.is_empty() {
                        app.input.clear();
                        app.cursor = 0;
                    } else if app.running {
                        cancel.lock().unwrap().cancel();
                    } else {
                        app.quit = true;
                    }
                }
                (KeyCode::Char('d'), KeyModifiers::CONTROL) if app.input.is_empty() => {
                    app.quit = true;
                }
                (KeyCode::Esc, _) => {
                    if app.running {
                        // First priority: interrupt the running session.
                        cancel.lock().unwrap().cancel();
                        app.push_notice("interrupting…");
                        app.last_esc = None;
                    } else if app
                        .last_esc
                        .map(|t| t.elapsed() < std::time::Duration::from_millis(1500))
                        .unwrap_or(false)
                    {
                        // Second Esc within 1.5s → quit the app.
                        app.quit = true;
                    } else {
                        app.last_esc = Some(std::time::Instant::now());
                        app.input.clear();
                        app.cursor = 0;
                        app.push_notice("press Esc again to quit");
                    }
                }
                (KeyCode::Enter, m) if m.contains(KeyModifiers::ALT) => {
                    app.input.insert(app.cursor, '\n');
                    app.cursor += 1;
                }
                (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
                    app.input.insert(app.cursor, '\n');
                    app.cursor += 1;
                }
                (KeyCode::Enter, _) => submit(app, cmd_tx),
                (KeyCode::Backspace, _) => {
                    if app.cursor > 0 {
                        let prev = floor_char_boundary(&app.input, app.cursor - 1);
                        app.input.replace_range(prev..app.cursor, "");
                        app.cursor = prev;
                    }
                }
                (KeyCode::Delete, _) => {
                    if app.cursor < app.input.len() {
                        let next = ceil_char_boundary(&app.input, app.cursor + 1);
                        app.input.replace_range(app.cursor..next, "");
                    }
                }
                (KeyCode::Left, _) => {
                    if app.cursor > 0 {
                        app.cursor = floor_char_boundary(&app.input, app.cursor - 1);
                    }
                }
                (KeyCode::Right, _) => {
                    if app.cursor < app.input.len() {
                        app.cursor = ceil_char_boundary(&app.input, app.cursor + 1);
                    }
                }
                (KeyCode::Home, _) => app.cursor = 0,
                (KeyCode::End, _) => app.cursor = app.input.len(),
                // Standard readline editing bindings.
                (KeyCode::Char('a'), KeyModifiers::CONTROL) => app.cursor = 0,
                (KeyCode::Char('e'), KeyModifiers::CONTROL) => app.cursor = app.input.len(),
                (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
                    // Kill from the cursor back to the start of the line.
                    app.input.replace_range(0..app.cursor, "");
                    app.cursor = 0;
                }
                (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
                    // Kill from the cursor to the end of the line.
                    app.input.truncate(app.cursor);
                }
                (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
                    // Delete the whitespace-delimited word before the cursor.
                    let start = prev_word_start(&app.input, app.cursor);
                    app.input.replace_range(start..app.cursor, "");
                    app.cursor = start;
                }
                (KeyCode::Up, _) => history_nav(app, -1),
                (KeyCode::Down, _) => history_nav(app, 1),
                (KeyCode::Char(c), m)
                    if !m.contains(KeyModifiers::CONTROL) && !m.contains(KeyModifiers::ALT) =>
                {
                    app.input.insert(app.cursor, c);
                    app.cursor += c.len_utf8();
                    // Typing new content exits history-browse mode, so Up/Down go
                    // back to autocomplete rather than continuing to walk history.
                    app.hist_idx = None;
                }
                _ => {}
            }
            app.refresh_suggestions();
        }
        Event::Paste(text) => {
            // Bracketed paste delivers the whole clipboard as ONE event, so we
            // handle each `text` independently. (An earlier "coalesce bursts"
            // pass re-inserted the combined text and duplicated everything
            // pasted so far.)
            // Normalize line endings first: clipboards/terminals deliver `\r\n`
            // (Windows) or a lone `\r` (older macOS / some paste paths). Left as
            // raw `\r`, the multi-line check below misses it and the carriage
            // return is inserted verbatim, jumping the cursor to column 0 and
            // corrupting both the composer render and the submitted text.
            let text = normalize_paste(&text);
            let insert = |app: &mut App, s: &str| {
                app.input.insert_str(app.cursor, s);
                app.cursor = app.cursor.saturating_add(s.len()).min(app.input.len());
            };

            if app.pending_input.is_some() {
                // Masked secret entry (e.g. /apikey): collapse to a single line.
                // Terminals split wrapped copies across lines, and a key with
                // embedded newlines would otherwise be saved invalid.
                let normalized: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
                insert(&mut *app, &normalized);
            } else if !text.contains('\n') && image_path_candidate(&text).is_some() {
                // A pasted / drag-and-dropped image path → attach it and show a
                // compact placeholder instead of dumping the path into the chat.
                let path = image_path_candidate(&text).unwrap();
                app.attachments.push(path);
                let token = image_placeholder(app.attachments.len());
                insert(&mut *app, &token);
                app.push_notice(
                    "image attached — send your message (needs a vision model, e.g. /model gemini)",
                );
            } else if text.contains('\n') {
                // Multi-line paste → a collapsible block; the real text is
                // expanded back in on submit so the composer stays readable.
                app.paste_seq += 1;
                let id = app.paste_seq;
                let token = paste_placeholder(id, &text);
                app.pastes.push((id, text.clone()));
                insert(&mut *app, &token);
            } else {
                // Ordinary single-line paste → insert verbatim.
                insert(&mut *app, &text);
            }
            app.suggestions.clear();
            app.sugg_idx = 0;
            app.refresh_suggestions();
        }
        _ => {}
    }
}

fn history_nav(app: &mut App, dir: i32) {
    if app.input_history.is_empty() {
        return;
    }
    let len = app.input_history.len();
    let next = match (app.hist_idx, dir) {
        (None, -1) => Some(len - 1),
        (None, _) => None,
        (Some(0), -1) => Some(0),
        (Some(i), -1) => Some(i - 1),
        (Some(i), 1) if i + 1 < len => Some(i + 1),
        (Some(_), 1) => None,
        (s, _) => s,
    };
    app.hist_idx = next;
    app.input = next.map(|i| app.input_history[i].clone()).unwrap_or_default();
    app.cursor = app.input.len();
}

/// Apply a value collected by an inline input prompt (e.g. an API key).
fn finish_input(app: &mut App, cmd_tx: &mpsc::UnboundedSender<WorkerCmd>) {
    let value = app.input.trim().to_string();
    app.input.clear();
    app.cursor = 0;
    let Some(pi) = app.pending_input.take() else { return };
    match pi.action {
        InputAction::ApiKey { model } => {
            if value.is_empty() {
                app.push_notice("no key entered — /model to switch, or /download a local model");
                return;
            }
            // Persist to ~/.lucid-train/config.toml (merges; never clobbers
            // other settings, and survives reinstalls).
            app.cfg.api_key = value.clone();
            if matches!(app.cfg.provider, Provider::Ollama | Provider::LucidCloud) {
                app.cfg.provider = Provider::OpenRouter;
                app.cfg.base_url = Provider::OpenRouter.default_base_url();
            }
            let _ = save_config_values(&[
                ("api_key", value.as_str()),
                ("provider", app.cfg.provider.label()),
                ("base_url", app.cfg.base_url.as_str()),
            ]);
            if let Some(m) = &model {
                switch_model(app, m, cmd_tx);
            } else {
                let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
            }
            app.push_notice("OpenRouter key saved to ~/.lucid-train/config.toml — re-send your message.");
        }
    }
}

/// Expand a leading `~/` to the home directory.
fn expand_user_path(p: &str) -> String {
    if let Some(rest) = p.strip_prefix("~/") {
        if let Some(home) = dirs::home_dir() {
            return home.join(rest).display().to_string();
        }
    }
    p.to_string()
}

/// If a pasted single line looks like a path to an existing image file, return
/// the cleaned-up path. Handles surrounding quotes and shell-escaped spaces
/// (macOS drag-and-drop pastes e.g. `/path/my\ pic.png`).
fn image_path_candidate(raw: &str) -> Option<String> {
    let mut s = raw.trim().to_string();
    if s.len() >= 2
        && ((s.starts_with('"') && s.ends_with('"'))
            || (s.starts_with('\'') && s.ends_with('\'')))
    {
        s = s[1..s.len() - 1].to_string();
    }
    s = s.replace("\\ ", " ");
    let ext_ok = std::path::Path::new(&s)
        .extension()
        .and_then(|e| e.to_str())
        .map(|e| {
            matches!(
                e.to_ascii_lowercase().as_str(),
                "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
            )
        })
        .unwrap_or(false);
    if !ext_ok {
        return None;
    }
    let expanded = expand_user_path(&s);
    std::path::Path::new(&expanded)
        .is_file()
        .then_some(expanded)
}

/// The composer placeholder shown for a paste block.
fn paste_placeholder(id: u32, content: &str) -> String {
    let lines = content.lines().count().max(1);
    let unit = if lines == 1 { "line" } else { "lines" };
    format!("[#{id} pasted {lines} {unit}]")
}

/// Normalize pasted line endings to `\n`. Terminals and clipboards deliver
/// `\r\n` (Windows) or a lone `\r` (older macOS / some paste paths); left as-is,
/// a `\r` is inserted verbatim and its carriage return jumps the cursor to
/// column 0, corrupting the composer render and the submitted message.
fn normalize_paste(text: &str) -> String {
    text.replace("\r\n", "\n").replace('\r', "\n")
}

/// Byte offset of the start of the whitespace-delimited word ending at `cursor`
/// (for Ctrl-W): skip whitespace immediately before the cursor, then the run of
/// non-whitespace before that. Returns 0 when no earlier word boundary exists.
fn prev_word_start(s: &str, cursor: usize) -> usize {
    let head = &s[..cursor];
    let no_trailing_ws = head.trim_end_matches(char::is_whitespace);
    match no_trailing_ws
        .char_indices()
        .rev()
        .find(|(_, c)| c.is_whitespace())
    {
        Some((i, c)) => i + c.len_utf8(),
        None => 0,
    }
}

/// The composer placeholder shown for the Nth attached image (1-based).
fn image_placeholder(n: usize) -> String {
    format!("[image {n}]")
}

/// Expand paste-block placeholders back to their content and strip `[image N]`
/// placeholders (images travel via attachments, not the message text).
fn expand_composer(input: &str, pastes: &[(u32, String)]) -> String {
    let mut text = input.to_string();
    for (id, content) in pastes {
        text = text.replace(&paste_placeholder(*id, content), content);
    }
    let mut out = String::with_capacity(text.len());
    let mut rest = text.as_str();
    while let Some(start) = rest.find("[image ") {
        let after = &rest[start + "[image ".len()..];
        if let Some(end_rel) = after.find(']') {
            let inner = &after[..end_rel];
            if !inner.is_empty() && inner.chars().all(|c| c.is_ascii_digit()) {
                out.push_str(&rest[..start]);
                rest = &after[end_rel + 1..];
                continue;
            }
        }
        out.push_str(&rest[..start + 1]);
        rest = &rest[start + 1..];
    }
    out.push_str(rest);
    out
}

fn submit(app: &mut App, cmd_tx: &mpsc::UnboundedSender<WorkerCmd>) {
    // Expand paste blocks back into the real text; image placeholders are
    // dropped here because the images are sent via `attachments`.
    let text = expand_composer(&app.input, &app.pastes).trim().to_string();
    // Allow an image-only message (empty text but attachments queued).
    if text.is_empty() && app.attachments.is_empty() {
        return;
    }
    app.input.clear();
    app.cursor = 0;
    app.pastes.clear();
    app.paste_seq = 0;
    app.hist_idx = None;
    app.input_history.push(text.clone());
    app.suggestions.clear();
    app.sugg_idx = 0;

    if let Some(stripped) = text.strip_prefix('/') {
        handle_slash_command(app, stripped, cmd_tx);
        return;
    }

    if app.running {
        app.push_notice("a turn is already running — press Esc to interrupt first");
        return;
    }

    // Encode any attached images and send them with this message.
    if app.attachments.is_empty() {
        app.add_cell(Cell::User(text.clone()));
        app.running = true;
        let _ = cmd_tx.send(WorkerCmd::UserMessage(text));
    } else {
        let mut urls = Vec::new();
        let mut names = Vec::new();
        let mut skipped = Vec::new();
        let paths: Vec<String> = app.attachments.drain(..).collect();
        for path in paths {
            match crate::tools::read::image_data_url(std::path::Path::new(&path)) {
                Some(url) => {
                    urls.push(url);
                    names.push(path);
                }
                None => skipped.push(path),
            }
        }
        for path in skipped {
            app.push_notice(format!("skipped {path} (not a readable image or too large)"));
        }
        let label = if names.is_empty() {
            text.clone()
        } else {
            // Show generic image labels, not the on-disk paths.
            let imgs: Vec<String> = (1..=names.len()).map(|i| format!("image {i}")).collect();
            format!("{text}  [🖼 {}]", imgs.join(", ")).trim().to_string()
        };
        app.add_cell(Cell::User(label));
        app.running = true;
        if urls.is_empty() {
            let _ = cmd_tx.send(WorkerCmd::UserMessage(text));
        } else {
            let _ = cmd_tx.send(WorkerCmd::UserMessageImages(text, urls));
        }
    }
}

fn handle_slash_command(app: &mut App, cmd: &str, cmd_tx: &mpsc::UnboundedSender<WorkerCmd>) {
    let mut parts = cmd.splitn(2, ' ');
    let name = parts.next().unwrap_or("");
    let arg = parts.next().unwrap_or("").trim();

    match name {
        "help" => app.add_cell(Cell::Help),
        "setup" => app.add_cell(Cell::Setup),
        "agent" | "plan" | "research" => {
            let mode = match name {
                "plan" => crate::config::Mode::Plan,
                "research" => crate::config::Mode::Research,
                _ => crate::config::Mode::Agent,
            };
            app.cfg.mode = mode;
            let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
            let desc = match mode {
                crate::config::Mode::Agent => "full tools — the agent edits files and runs commands",
                crate::config::Mode::Plan => "propose-only — no tools run; you get a reviewed plan",
                crate::config::Mode::Research => "web_search + read_url only — cited answers, no shell",
            };
            app.push_notice(format!("mode: {} ({desc})", mode.label()));
        }
        "quit" | "exit" | "q" => app.quit = true,
        "download" | "pull" => {
            if arg.is_empty() {
                app.push_notice("usage: /download <tag> — see /setup for the catalog (e.g. /download qwen3:4b)");
                return;
            }
            let tag = arg.to_string();
            if !app.ollama_available {
                // Zero-setup: install Ollama right here, then run the download.
                if app.ollama_installing {
                    app.pending_download = Some(tag);
                    app.push_notice("Ollama is still installing — the download will start automatically when it finishes");
                    return;
                }
                app.ollama_installing = true;
                app.pending_download = Some(tag);
                app.add_cell(Cell::Download { tag: "ollama".into(), line: "installing Ollama (one-time setup)…".into(), done: false, ok: false });
                spawn_install_ollama(app);
                return;
            }
            if let Some(m) = find_local(&tag) {
                // Warn if RAM looks tight, but never block — you know your
                // machine (big unified-memory Macs, multi-GPU rigs, etc.).
                if m.min_ram_gb > app.hardware.budget_gb() {
                    app.push_notice(format!(
                        "heads up: {} typically wants ~{:.0} GB RAM and this machine reports {:.0} GB — it may run slowly or swap. Downloading anyway.",
                        m.tag, m.min_ram_gb, app.hardware.budget_gb()
                    ));
                }
                if !m.tool_calling {
                    app.push_notice(format!("note: {} does not support tool calling — fine for chat, but the agent cannot run commands with it.", m.tag));
                }
            }
            app.add_cell(Cell::Download { tag: tag.clone(), line: "starting download…".into(), done: false, ok: false });
            spawn_download(app, tag);
        }
        "apikey" => {
            if arg.is_empty() {
                // Open a masked input field instead of echoing the key.
                app.pending_input = Some(PendingInput {
                    label: "OpenRouter API key".into(),
                    hint: "paste your key (get one at openrouter.ai/keys) — Enter to save, Esc to cancel".into(),
                    masked: true,
                    action: InputAction::ApiKey { model: None },
                });
                return;
            }
            let key = arg.to_string();
            app.cfg.api_key = key.clone();
            if app.cfg.provider == Provider::Ollama || app.cfg.provider == Provider::LucidCloud {
                app.cfg.provider = Provider::OpenRouter;
                app.cfg.base_url = Provider::OpenRouter.default_base_url();
            }
            let _ = save_config_values(&[
                ("api_key", key.as_str()),
                ("provider", app.cfg.provider.label()),
                ("base_url", app.cfg.base_url.as_str()),
            ]);
            let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
            app.push_notice("OpenRouter key saved to ~/.lucid-train/config.toml — all OpenRouter models unlocked (/models).");
        }
        // ---- Codex / Sign in with ChatGPT ----
        "login" => {
            match arg.trim().to_ascii_lowercase().as_str() {
                "codex" | "chatgpt" => {
                    app.push_notice(
                        "signing in with ChatGPT — opening your browser… (uses the Codex subscription endpoint, which is undocumented and subject to OpenAI's terms).",
                    );
                    start_codex_login(app, false);
                }
                _ => {
                    app.push_notice(
                        "signing in to Lucid Cloud — opening your browser to sign in with Google. Return here when it's done.",
                    );
                    spawn_login(app);
                }
            }
        }
        "onboarding" | "welcome" | "onboard" => {
            let _ = arg;
            app.onboarding = Some(Onboard { selected: 0 });
        }
        "effort" | "reasoning" => {
            let want = arg.trim().to_ascii_lowercase();
            if !matches!(want.as_str(), "low" | "medium" | "high" | "xhigh") {
                app.push_notice(format!(
                    "usage: /effort low|medium|high|xhigh (current: {}). Applies to GPT/Codex models.",
                    app.cfg.reasoning_effort
                ));
                return;
            }
            app.cfg.reasoning_effort = want.clone();
            let _ = save_config_values(&[("reasoning_effort", want.as_str())]);
            let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
            let note = if app.cfg.provider == Provider::Codex {
                ""
            } else {
                " (only affects GPT/Codex models — /provider codex or /model gpt-5.5)"
            };
            app.push_notice(format!("reasoning effort set to {want}{note}"));
        }
        "credits" | "balance" | "billing" => {
            spawn_credits(app, arg.to_string());
        }
        "logout" => {
            let _ = arg;
            spawn_logout(app);
            app.cfg.lucid_token.clear();
            app.cfg.lucid_email.clear();
            let _ = save_config_values(&[("lucid_token", ""), ("lucid_email", "")]);
            app.push_notice("signing out of Lucid Cloud…");
        }
        "new" | "clear" => {
            let _ = cmd_tx.send(WorkerCmd::ClearHistory);
            app.cells = vec![Cell::Header, Cell::Help];
            app.tokens_used = 0;
            app.attachments.clear();
            app.push_notice("started a new session");
        }
        "compact" => {
            let _ = cmd_tx.send(WorkerCmd::Compact);
        }
        "attach" => {
            if arg.is_empty() {
                if app.attachments.is_empty() {
                    app.push_notice("usage: /attach <image-path> — attaches an image to your next message (needs a vision model, e.g. /model gemini or a local llava/qwen2.5-vl)");
                } else {
                    app.push_notice(format!("attached: {} — clears after your next message", app.attachments.join(", ")));
                }
                return;
            }
            let expanded = if let Some(rest) = arg.strip_prefix("~/") {
                dirs::home_dir().map(|h| h.join(rest).display().to_string()).unwrap_or_else(|| arg.to_string())
            } else {
                arg.to_string()
            };
            let path = std::path::Path::new(&expanded);
            if !path.exists() {
                app.push_notice(format!("file not found: {expanded}"));
                return;
            }
            if crate::tools::read::image_data_url(path).is_none() {
                app.push_notice(format!("{expanded} is not a supported image (png/jpg/gif/webp/bmp, ≤10 MB)"));
                return;
            }
            app.attachments.push(expanded.clone());
            let info = crate::tools::read::image_info(path);
            app.push_notice(format!("attached {info} — send a message to include it (vision model required)"));
        }
        "update" => {
            match arg {
                "off" => {
                    app.cfg.update_check = false;
                    let _ = save_config_values(&[("update_check", "false")]);
                    app.push_notice("update checks disabled (re-enable: /update on)");
                }
                "on" => {
                    app.cfg.update_check = true;
                    let _ = save_config_values(&[("update_check", "true")]);
                    app.push_notice("update checks enabled");
                }
                _ => {
                    // Force a check now; clear any skip so a pending update re-prompts.
                    app.cfg.skipped_version.clear();
                    app.push_notice(format!("you're on v{} — checking for updates…", crate::update::CURRENT));
                    let utx = app.ui_tx.clone();
                    tokio::spawn(async move {
                        match crate::update::latest_version().await {
                            Some(latest) if crate::update::is_newer(&latest, crate::update::CURRENT) => {
                                let _ = utx.send(UiEvent::UpdateAvailable { latest });
                            }
                            Some(latest) => {
                                let _ = utx.send(UiEvent::CreditsResult(format!("up to date (latest is v{latest})")));
                            }
                            None => {
                                let _ = utx.send(UiEvent::CreditsResult("could not reach the update server (offline?)".into()));
                            }
                        }
                    });
                }
            }
        }
        "copy" => {
            let what = if arg.is_empty() { "last" } else { arg };
            let text: Option<String> = match what {
                "last" | "reply" => app.cells.iter().rev().find_map(|c| match c {
                    Cell::Assistant(t) => Some(t.clone()),
                    _ => None,
                }),
                "output" | "exec" => app.cells.iter().rev().find_map(|c| match c {
                    Cell::Exec { command, output_tail, .. } => {
                        Some(format!("$ {command}\n{output_tail}"))
                    }
                    _ => None,
                }),
                "all" => {
                    let mut s = String::new();
                    for c in &app.cells {
                        match c {
                            Cell::User(t) => s.push_str(&format!("› {t}\n\n")),
                            Cell::Assistant(t) => s.push_str(&format!("{t}\n\n")),
                            Cell::Exec { command, output_tail, .. } => {
                                s.push_str(&format!("$ {command}\n{output_tail}\n\n"))
                            }
                            _ => {}
                        }
                    }
                    if s.is_empty() { None } else { Some(s) }
                }
                other => {
                    app.push_notice(format!("usage: /copy [last|output|all] — '{other}' not recognized"));
                    return;
                }
            };
            match text {
                Some(t) => match copy_to_clipboard(&t) {
                    Ok(()) => app.push_notice(format!("copied {what} to clipboard ({} chars)", t.len())),
                    Err(e) => app.push_notice(format!("copy failed: {e}")),
                },
                None => app.push_notice(format!("nothing to copy for '{what}' yet")),
            }
        }
        "status" => {
            let pct = if app.cfg.max_context_tokens > 0 {
                app.tokens_used as u64 * 100 / app.cfg.max_context_tokens as u64
            } else {
                0
            };
            let codex_line = if app.cfg.provider == Provider::Codex {
                match crate::codex_auth::load() {
                    Some(t) => format!(
                        " • codex: signed in as {} • effort {}",
                        crate::codex_auth::account_label(&t),
                        app.cfg.reasoning_effort
                    ),
                    None => " • codex: not signed in (/login)".to_string(),
                }
            } else {
                String::new()
            };
            app.push_notice(format!(
                "model {} • provider {} • base {} • approvals {} • context {}/{} ({pct}%){codex_line}",
                app.cfg.model,
                app.cfg.provider.label(),
                app.cfg.base_url,
                app.cfg.approval.label(),
                app.tokens_used,
                app.cfg.max_context_tokens,
            ));
        }
        "models" => {
            let mut s = String::from("available model presets:\n");
            for p in MODEL_PRESETS {
                s.push_str(&format!(
                    "  {:<22} {:<34} [{}] {:>5} ctx — {}\n",
                    p.alias,
                    p.id,
                    p.provider.label(),
                    crate::config::fmt_context_window(p.context_tokens),
                    p.note
                ));
            }
            s.push_str("  (or /model any-provider/any-id for OpenRouter, /provider custom + base url for self-hosted vLLM/llama.cpp)");
            app.push_notice(s);
        }
        "model" => {
            if arg.is_empty() {
                app.push_notice(format!(
                    "current model: {} ({}). Use /model <alias-or-tag>; /setup shows the catalog.",
                    app.cfg.model_alias, app.cfg.model
                ));
                return;
            }
            switch_model(app, arg, cmd_tx);
        }
        "provider" => {
            if arg.is_empty() {
                app.push_notice(format!(
                    "current provider: {} ({}). Use /provider openrouter|openai|ollama|<base-url>",
                    app.cfg.provider.label(),
                    app.cfg.base_url
                ));
                return;
            }
            let mut cfg = app.cfg.clone();
            if Provider::parse(arg) == Some(Provider::LucidCloud) && app.cfg.lucid_token.is_empty() {
                app.push_notice("Lucid Cloud needs sign-in first — run /login (Google), then /provider lucid.");
                return;
            }
            if let Some(p) = Provider::parse(arg) {
                cfg.provider = p;
                cfg.base_url = p.default_base_url().to_string();
            } else if arg.starts_with("http") {
                cfg.provider = Provider::Custom;
                cfg.base_url = arg.trim_end_matches('/').to_string();
            } else {
                app.push_notice(format!("unknown provider '{arg}'"));
                return;
            }
            if cfg.provider == Provider::Ollama && cfg.api_key.is_empty() {
                cfg.api_key = "ollama".into();
            }
            if cfg.provider == Provider::LucidCloud {
                cfg.api_key = cfg.lucid_token.clone();
            }
            if cfg.provider == Provider::Codex {
                cfg.api_key = "codex".into();
                if crate::codex_auth::load().is_none() {
                    // Auto-start login; CodexLoginResult will set provider + a
                    // default model. No need for a separate /login step.
                    app.pending_codex_model = Some("gpt-5.5".into());
                    app.push_notice("provider set to codex — signing in with ChatGPT now…");
                    start_codex_login(app, false);
                    return;
                }
            }
            app.cfg = cfg.clone();
            let _ = cmd_tx.send(WorkerCmd::Reconfigure(cfg));
            app.push_notice(format!("provider set to {} ({})", app.cfg.provider.label(), app.cfg.base_url));
        }
        "approvals" | "approval" => {
            if let Some(p) = ApprovalPolicy::parse(arg) {
                if p == ApprovalPolicy::FullAuto && app.cfg.approval != ApprovalPolicy::FullAuto {
                    // Dangerous escalation: confirm before letting everything auto-run.
                    app.approval = Some(PendingApproval {
                        kind: ApprovalKind::FullAuto,
                        command: "switch to FULL-AUTO: every command (including dangerous ones like rm -rf, sudo, git reset) runs without asking — only catastrophic commands stay blocked".into(),
                        risk_label: "confirm".into(),
                        reason: "policy change".into(),
                        request_id: None,
                    });
                    return;
                }
                app.cfg.approval = p;
                let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                app.push_notice(format!("approval policy: {}", p.label()));
            } else {
                app.push_notice("usage: /approvals plan|auto|full-auto");
            }
        }
        "delete" | "rm" => {
            if arg.is_empty() {
                app.push_notice("usage: /delete <tag> — removes a downloaded local model (autocomplete lists installed ones)");
                return;
            }
            if !app.tag_installed(arg) {
                app.push_notice(format!("{arg} is not an installed local model — /delete autocompletes from installed tags"));
                return;
            }
            if app.cfg.provider == Provider::Ollama && app.cfg.model == arg {
                app.push_notice("that model is currently selected — switch first (/model …), then delete");
                return;
            }
            spawn_delete(app, arg.to_string());
        }
        "skills" => {
            let mut parts = arg.splitn(2, ' ');
            let sub = parts.next().unwrap_or("");
            let sub_arg = parts.next().unwrap_or("").trim();
            match sub {
                "" | "list" => {
                    let skills = crate::skills::discover(&app.cfg.cwd);
                    if skills.is_empty() {
                        app.push_notice("no skills installed yet. Find some: /skills search <topic> (browses skills.sh), then /skills install <owner/repo> [skill-name]. SKILL.md packs in ~/.lucid-train/skills, .lucid/skills, or ~/.claude/skills also work.");
                    } else {
                        let mut s = format!("{} skill(s) available to the agent:\n", skills.len());
                        for sk in skills {
                            s.push_str(&format!("  {} — {} ({})\n", sk.name, sk.description, sk.path.display()));
                        }
                        s.push_str("more: /skills search <topic>  •  /skills install <owner/repo> [skill]");
                        app.push_notice(s);
                    }
                }
                "search" | "find" => {
                    if sub_arg.is_empty() {
                        app.push_notice("usage: /skills search <topic> — searches skills.sh");
                        return;
                    }
                    app.push_notice(format!("searching skills.sh for '{sub_arg}'…"));
                    spawn_skills_search(app, sub_arg.to_string());
                }
                "install" | "add" => {
                    let mut words = sub_arg.split_whitespace();
                    let Some(repo) = words.next() else {
                        app.push_notice("usage: /skills install <owner/repo> [skill-name]\n  e.g. /skills install vercel-labs/agent-skills frontend-design");
                        return;
                    };
                    let mut cli_args = vec!["add".to_string(), repo.to_string()];
                    if let Some(skill) = words.next() {
                        cli_args.push("--skill".into());
                        cli_args.push(skill.to_string());
                    }
                    // Install globally for claude-code: lands in ~/.claude/skills,
                    // which lucid-train discovers automatically.
                    cli_args.extend(["-a".into(), "claude-code".into(), "-g".into(), "-y".into()]);
                    let label = format!("skill {repo}");
                    app.add_cell(Cell::Download { tag: label.clone(), line: "installing via skills.sh…".into(), done: false, ok: false });
                    spawn_skills_cli(app, cli_args, label);
                }
                other => app.push_notice(format!("unknown subcommand '/skills {other}' — use list|search|install")),
            }
        }
        "mcp" => {
            let mut parts = arg.splitn(2, ' ');
            let sub = parts.next().unwrap_or("");
            let sub_arg = parts.next().unwrap_or("").trim();
            match sub {
                "" | "list" => {
                    if app.cfg.mcp_servers.is_empty() {
                        app.push_notice("no MCP servers configured. Add one: /mcp add <name> <command> [args…]\n  e.g. /mcp add github npx -y @modelcontextprotocol/server-github\nTools connect on the next message and appear as mcp__<server>__<tool>.");
                    } else {
                        let mut s = format!("{} MCP server(s) configured (connect on next message):\n", app.cfg.mcp_servers.len());
                        for m in &app.cfg.mcp_servers {
                            s.push_str(&format!("  {} — {} {}\n", m.name, m.command, m.args.join(" ")));
                        }
                        app.push_notice(s);
                    }
                }
                "add" => {
                    let mut words = sub_arg.split_whitespace();
                    let (Some(name), Some(command)) = (words.next(), words.next()) else {
                        app.push_notice("usage: /mcp add <name> <command> [args…]");
                        return;
                    };
                    let args_vec: Vec<String> = words.map(String::from).collect();
                    if save_mcp_server(name, command, &args_vec, &[]).is_ok() {
                        app.cfg.mcp_servers.retain(|m| m.name != name);
                        app.cfg.mcp_servers.push(crate::mcp::McpServerConfig {
                            name: name.to_string(),
                            command: command.to_string(),
                            args: args_vec,
                            env: Vec::new(),
                        });
                        let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                        app.push_notice(format!("MCP server '{name}' added — its tools connect on your next message"));
                    } else {
                        app.push_notice("failed to write config.toml");
                    }
                }
                "remove" | "rm" => {
                    match remove_mcp_server(sub_arg) {
                        Ok(true) => {
                            app.cfg.mcp_servers.retain(|m| m.name != sub_arg);
                            let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                            app.push_notice(format!("MCP server '{sub_arg}' removed"));
                        }
                        Ok(false) => app.push_notice(format!("no MCP server named '{sub_arg}'")),
                        Err(e) => app.push_notice(format!("failed: {e}")),
                    }
                }
                other => app.push_notice(format!("unknown subcommand '/mcp {other}' — use list|add|remove")),
            }
        }
        other => {
            // Resolve unambiguous prefixes: "/ski" → "/skills".
            let candidates: Vec<&str> = COMMANDS
                .iter()
                .map(|(c, _)| &c[1..])
                .filter(|c| c.starts_with(other))
                .collect();
            match candidates.as_slice() {
                [single] => {
                    let full = if arg.is_empty() {
                        single.to_string()
                    } else {
                        format!("{single} {arg}")
                    };
                    handle_slash_command(app, &full, cmd_tx);
                }
                [] => app.push_notice(format!("unknown command /{other} — try /help")),
                many => app.push_notice(format!(
                    "ambiguous command /{other} — matches: {}",
                    many.iter().map(|c| format!("/{c}")).collect::<Vec<_>>().join(", ")
                )),
            }
        }
    }
}

/// Model switching with hardware + access gating:
/// - local tags: must be downloaded; RAM is advisory (warn, never block)
/// - OpenRouter models: need your own OpenRouter API key (/apikey)
/// Truncate `text` to `max` bytes, keeping a UTF-8 safe prefix and appending a
/// truncation marker. No-op if the text is already within the limit.
fn cap_text(text: &mut String, max: usize) {
    if text.len() <= max {
        return;
    }
    let omitted = text.len() - max;
    let mut i = max;
    while i > 0 && !text.is_char_boundary(i) {
        i -= 1;
    }
    let prefix = text[..i].to_string();
    let marker = format!("\n…[truncated, {} chars omitted]", omitted);
    text.clear();
    text.push_str(&prefix);
    text.push_str(&marker);
}

fn switch_model(app: &mut App, arg: &str, cmd_tx: &mpsc::UnboundedSender<WorkerCmd>) {
    // ---- Lucid Cloud (logged-in proxy) ----
    // When signed in on Lucid Cloud, switching among the served models keeps the
    // provider + token; the backend gates and meters each request. Selecting a
    // model Lucid Cloud doesn't serve falls through to the BYO/local logic.
    if app.cfg.provider == Provider::LucidCloud && !app.cfg.lucid_token.is_empty() {
        if let Some(p) = crate::config::find_preset(arg) {
            if matches!(p.id, "tencent/hy3:free" | "deepseek/deepseek-v4-pro" | "z-ai/glm-5.2" | "z-ai/glm-5.1" | "moonshotai/kimi-k2.7-code") {
                app.cfg.model = p.id.to_string();
                app.cfg.model_alias = p.alias.to_string();
                app.cfg.max_context_tokens = p.context_tokens;
                let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
                let _ = save_config_values(&[("model", p.id)]);
                app.push_notice(format!("switched to {} ({}) on Lucid Cloud", p.alias, p.id));
                return;
            }
        }
    }

    // ---- local model (ollama tag, e.g. qwen3:4b) ----
    let looks_local = find_local(arg).is_some() || (arg.contains(':') && !arg.contains('/'));
    if looks_local {
        let budget = app.hardware.budget_gb();
        if let Some(m) = find_local(arg) {
            // RAM is a hint, not a gate — capable machines run big models.
            if m.min_ram_gb > budget {
                app.push_notice(format!(
                    "heads up: {} typically wants ~{:.0} GB RAM (this machine reports {:.0} GB) — may be slow. Switching anyway.",
                    m.tag, m.min_ram_gb, budget
                ));
            }
            if !m.tool_calling {
                app.push_notice(format!("warning: {} has no tool calling — the agent cannot run commands with it.", m.tag));
            }
        }
        if !app.ollama_available {
            app.push_notice("Ollama is not available — install it (brew install ollama) or pick an OpenRouter model with /apikey.");
            return;
        }
        let downloaded = app
            .ollama_tags
            .iter()
            .any(|t| t == arg || t.trim_end_matches(":latest") == arg);
        if !downloaded {
            let size = find_local(arg).map(|m| format!(" (~{:.1} GB download)", m.download_gb)).unwrap_or_default();
            app.push_notice(format!("{arg} is not downloaded yet — run /download {arg}{size}"));
            return;
        }
        app.cfg.provider = Provider::Ollama;
        app.cfg.base_url = Provider::Ollama.default_base_url();
        // Preserve the user's cloud key locally so switching back to an
        // OpenRouter model in the same session doesn't lose it. Ollama
        // ignores the Authorization header, so an existing key is harmless.
        app.cfg.model = arg.to_string();
        app.cfg.model_alias = arg.to_string();
        // The local server is pinned to OLLAMA_CONTEXT_LENGTH (see
        // spawn_ollama_serve), so the real window is this — not the model's max.
        app.cfg.max_context_tokens = crate::config::OLLAMA_RUNTIME_CONTEXT_TOKENS;
        let _ = cmd_tx.send(WorkerCmd::Reconfigure(app.cfg.clone()));
        // Save provider so restarts default to local, but keep api_key in the
        // config file untouched so it is still there when the user switches
        // back to a cloud model.
        let _ = save_config_values(&[("model", arg), ("provider", "ollama")]);
        app.push_notice(format!("switched to local model {arg} (runs on-device, free)"));
        return;
    }

    // ---- Codex / ChatGPT subscription model ----
    let preset = crate::config::find_preset(arg);
    let is_codex = preset.map(|p| p.provider == Provider::Codex).unwrap_or(false)
        || (app.cfg.provider == Provider::Codex && find_cloud(arg).is_none());
    if is_codex {
        let model_id = preset.map(|p| p.id.to_string()).unwrap_or_else(|| arg.to_string());
        if crate::codex_auth::load().is_none() {
            // Not signed in yet — kick off the login flow now and apply this
            // model the moment it completes (no second command needed).
            app.pending_codex_model = Some(model_id.clone());
            app.push_notice(format!(
                "signing in to ChatGPT to use {model_id} — importing an existing Codex login or opening your browser…"
            ));
            start_codex_login(app, false);
            return;
        }
        let alias = preset.map(|p| p.alias.to_string()).unwrap_or_else(|| arg.to_string());
        app.cfg.provider = Provider::Codex;
        app.cfg.base_url = Provider::Codex.default_base_url();
        app.cfg.api_key = "codex".into();
        let mut cfg = app.cfg.clone();
        apply_model(&mut cfg, &model_id, true, true);
        cfg.model = model_id.clone();
        cfg.model_alias = alias.clone();
        app.cfg = cfg.clone();
        let _ = cmd_tx.send(WorkerCmd::Reconfigure(cfg));
        let _ = save_config_values(&[("model", model_id.as_str()), ("provider", Provider::Codex.label())]);
        app.push_notice(format!("switched to {alias} ({model_id}) on your ChatGPT subscription"));
        return;
    }

    // ---- OpenRouter model (bring your own key) ----
    let cloud = find_cloud(arg);
    let model_id = cloud.map(|c| c.id.to_string()).unwrap_or_else(|| arg.to_string());
    let alias = cloud.map(|c| c.alias.to_string()).unwrap_or_else(|| arg.to_string());
    let has_own_key = !app.cfg.api_key.is_empty()
        && app.cfg.api_key != "ollama"
        && app.cfg.provider != Provider::LucidCloud;
    let own_key_env = std::env::var("OPENROUTER_API_KEY").ok().filter(|s| !s.is_empty());

    if has_own_key && matches!(app.cfg.provider, Provider::OpenRouter | Provider::OpenAi | Provider::Custom) {
        // keep current provider/key, just switch the model
    } else if let Some(key) = own_key_env {
        app.cfg.provider = Provider::OpenRouter;
        app.cfg.base_url = Provider::OpenRouter.default_base_url();
        app.cfg.api_key = key;
    } else {
        // No key: offer sign-in first (cloud models, billed from plan), with a
        // fall-back to pasting an OpenRouter key. Remember the model so we apply
        // it after the choice.
        app.push_notice(format!("{alias} is a cloud model — sign in to run it, or bring an OpenRouter key"));
        prompt_login_or_key(app, model_id);
        return;
    }

    let mut cfg = app.cfg.clone();
    apply_model(&mut cfg, &model_id, true, true);
    cfg.model_alias = alias.clone();
    app.cfg = cfg.clone();
    let _ = cmd_tx.send(WorkerCmd::Reconfigure(cfg));
    let _ = save_config_values(&[("model", model_id.as_str())]);
    app.push_notice(format!(
        "switched to {alias} ({model_id}) via {}",
        app.cfg.provider.label()
    ));
}

fn floor_char_boundary(s: &str, mut i: usize) -> usize {
    while i > 0 && !s.is_char_boundary(i) {
        i -= 1;
    }
    i
}

fn ceil_char_boundary(s: &str, mut i: usize) -> usize {
    while i < s.len() && !s.is_char_boundary(i) {
        i += 1;
    }
    i
}

#[cfg(test)]
mod composer_tests {
    use super::{
        expand_composer, image_path_candidate, image_placeholder, normalize_paste,
        paste_placeholder, prev_word_start,
    };

    #[test]
    fn prev_word_start_finds_word_boundary() {
        // Cursor at end: delete "ijkl".
        assert_eq!(prev_word_start("abcd efgh ijkl", 14), 10);
        // Trailing spaces are skipped, then the word before them.
        assert_eq!(prev_word_start("abcd efgh   ", 12), 5);
        // Single word → back to start.
        assert_eq!(prev_word_start("word", 4), 0);
        // Empty / start of line.
        assert_eq!(prev_word_start("", 0), 0);
        // Multibyte word boundary stays on a char boundary.
        let s = "héllo wörld";
        assert_eq!(&s[prev_word_start(s, s.len())..], "wörld");
    }

    #[test]
    fn paste_placeholder_pluralizes() {
        assert_eq!(paste_placeholder(1, "a\nb\nc"), "[#1 pasted 3 lines]");
        assert_eq!(paste_placeholder(2, "single"), "[#2 pasted 1 line]");
    }

    #[test]
    fn normalize_paste_unifies_line_endings() {
        // Windows CRLF, lone CR, and mixed all collapse to \n.
        assert_eq!(normalize_paste("a\r\nb\rc\nd"), "a\nb\nc\nd");
        assert_eq!(normalize_paste("plain single line"), "plain single line");
        // A pure-\r multi-line paste must become newline-detectable so the
        // handler routes it to the collapsible placeholder, not verbatim insert.
        let n = normalize_paste("x\ry\rz");
        assert!(n.contains('\n') && !n.contains('\r'), "got: {n:?}");
        assert_eq!(paste_placeholder(1, &n), "[#1 pasted 3 lines]");
    }

    #[test]
    fn expand_composer_restores_paste_blocks() {
        let pastes = vec![(1u32, "line one\nline two".to_string())];
        let input = format!("before {} after", paste_placeholder(1, &pastes[0].1));
        assert_eq!(expand_composer(&input, &pastes), "before line one\nline two after");
    }

    #[test]
    fn expand_composer_strips_image_placeholders() {
        let input = format!("look {} and {}", image_placeholder(1), image_placeholder(2));
        // Image tokens are removed (images travel via attachments).
        assert_eq!(expand_composer(&input, &[]), "look  and ");
    }

    #[test]
    fn expand_composer_keeps_non_image_brackets() {
        assert_eq!(expand_composer("an [array] and [image x]", &[]), "an [array] and [image x]");
    }

    #[test]
    fn image_path_candidate_detects_and_rejects() {
        let dir = std::env::temp_dir().join(format!("lucid-img-{}", std::process::id()));
        let _ = std::fs::create_dir_all(&dir);
        let img = dir.join("pic.png");
        std::fs::write(&img, b"\x89PNG\r\n").unwrap();

        let raw = img.display().to_string();
        assert_eq!(image_path_candidate(&raw).as_deref(), Some(raw.as_str()));
        // Quoted + escaped-space variants normalise to the same path.
        assert!(image_path_candidate(&format!("\"{raw}\"")).is_some());
        // Non-image text and missing files are rejected.
        assert!(image_path_candidate("just some words").is_none());
        assert!(image_path_candidate("/nope/missing.png").is_none());

        let _ = std::fs::remove_dir_all(&dir);
    }
}

#[cfg(test)]
mod render_tests {
    use super::*;
    use ratatui::backend::TestBackend;
    use ratatui::{Terminal, TerminalOptions, Viewport};

    fn make_app(n: usize) -> App {
        let (tx, _rx) = mpsc::unbounded_channel();
        let mut app = App::new(crate::config::Config::default(), crate::hardware::Hardware::default(), tx);
        app.intro_ticks = None;
        app.onboarding = None;
        app.cells.clear();
        for i in 0..n {
            app.cells.push(Cell::User(format!("msg {i:03}")));
        }
        app
    }

    /// Render one full-screen frame to a TestBackend and return it as text.
    fn screen(app: &mut App, w: u16, h: u16) -> String {
        let mut term = Terminal::new(TestBackend::new(w, h)).unwrap();
        term.draw(|f| render::draw(f, app)).unwrap();
        let buf = term.backend().buffer().clone();
        buf_text(&buf)
    }

    fn buf_text(buf: &ratatui::buffer::Buffer) -> String {
        let mut s = String::new();
        for y in 0..buf.area.height {
            for x in 0..buf.area.width {
                s.push_str(buf[(x, y)].symbol());
            }
            s.push('\n');
        }
        s
    }

    #[test]
    fn live_region_shows_newest_and_tail_clips() {
        // The live region only holds not-yet-committed cells; it is bottom-
        // anchored and tail-clipped (it has no scrollback of its own — the
        // terminal does).
        let mut app = make_app(60);
        let s = screen(&mut app, 40, 14);
        assert!(s.contains("msg 059"), "newest live cell should be at the bottom:\n{s}");
        assert!(!s.contains("msg 000"), "older cells should be tail-clipped");
    }

    #[test]
    fn viewport_height_tracks_coarse_state() {
        let mut app = make_app(0);
        let idle = desired_viewport_height(&app, 50);
        app.running = true;
        let active = desired_viewport_height(&app, 50);
        assert_eq!(active - idle, LIVE_CAP - 1, "active turn reserves the live area");
        // The approval modal adds its fixed height.
        app.running = false;
        app.approval = Some(PendingApproval {
            kind: ApprovalKind::FullAuto,
            command: String::new(),
            risk_label: String::new(),
            reason: String::new(),
            request_id: None,
        });
        assert_eq!(desired_viewport_height(&app, 50), idle + 6);
        // Always clamped below the terminal height.
        assert!(desired_viewport_height(&app, 4) <= 3);
    }

    #[test]
    fn intro_takes_full_height() {
        let mut app = make_app(0);
        app.intro_ticks = Some(0);
        assert_eq!(desired_viewport_height(&app, 30), 29);
    }

    #[test]
    fn commit_cells_emits_to_scrollback() {
        // A settled cell pushed through the commit path lands in the backend
        // buffer (above the small inline viewport).
        let mut term = Terminal::with_options(
            TestBackend::new(40, 20),
            TerminalOptions { viewport: Viewport::Inline(4) },
        )
        .unwrap();
        let app = make_app(0);
        commit_cells(&mut term, &app, vec![Cell::User("hello scrollback".into())], 38).unwrap();
        let s = buf_text(&term.backend().buffer().clone());
        assert!(s.contains("hello scrollback"), "committed cell should be in scrollback:\n{s}");
    }

    #[test]
    fn pending_input_box_not_collapsed_in_short_viewport() {
        // In the small idle inline viewport, the input must keep its full 3-row
        // bordered box (Min(0) on the live region) — not collapse to one line.
        let mut app = make_app(0);
        app.pending_input = Some(PendingInput {
            label: "OpenRouter API key required".into(),
            hint: "paste your key".into(),
            masked: true,
            action: InputAction::ApiKey { model: Some("deepseek/deepseek-v4-pro".into()) },
        });
        let s = screen(&mut app, 60, 6);
        assert!(s.contains("OpenRouter API key required"), "title border row:\n{s}");
        assert!(s.contains('└'), "input box bottom border should render (3-row box):\n{s}");
    }

    #[test]
    fn login_or_key_modal_renders_both_choices() {
        let mut app = make_app(0);
        app.approval = Some(PendingApproval {
            kind: ApprovalKind::LoginOrKey { model: "deepseek/deepseek-v4-pro".into() },
            command: "sign in for cloud models, or bring your own OpenRouter key".into(),
            risk_label: "sign in".into(),
            reason: String::new(),
            request_id: None,
        });
        let s = screen(&mut app, 80, 12);
        assert!(s.contains("Cloud model needs sign-in"), "question:\n{s}");
        assert!(s.contains("log in"), "login option:\n{s}");
        assert!(s.contains("OpenRouter key"), "key fallback option:\n{s}");
    }
}

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

    fn app() -> App {
        let (tx, _rx) = mpsc::unbounded_channel();
        let mut a = App::new(crate::config::Config::default(), crate::hardware::Hardware::default(), tx);
        a.cells.clear();
        a.intro_ticks = None;
        a.onboarding = None;
        a
    }

    fn exec(status: ExecStatus) -> Cell {
        Cell::Exec {
            call_id: "1".into(),
            command: "ls".into(),
            description: String::new(),
            output_tail: String::new(),
            status,
            duration_ms: 0,
            background: false,
        }
    }

    #[test]
    fn plain_cells_all_settled() {
        let mut a = app();
        a.cells.push(Cell::User("hi".into()));
        a.cells.push(Cell::Assistant("hello".into())); // not streaming → settled
        assert_eq!(settled_prefix_len(&a), 2);
    }

    #[test]
    fn streaming_assistant_is_live() {
        let mut a = app();
        a.cells.push(Cell::User("hi".into()));
        a.cells.push(Cell::Assistant("partial".into()));
        a.assistant_streaming = true;
        assert_eq!(settled_prefix_len(&a), 1);
    }

    #[test]
    fn running_exec_is_live_until_it_ends() {
        let mut a = app();
        a.cells.push(Cell::User("hi".into()));
        a.cells.push(exec(ExecStatus::Running));
        assert_eq!(settled_prefix_len(&a), 1);
        if let Cell::Exec { status, .. } = &mut a.cells[1] {
            *status = ExecStatus::Ok;
        }
        assert_eq!(settled_prefix_len(&a), 2);
    }

    #[test]
    fn order_preserved_settled_behind_live() {
        // A settled cell sitting behind a still-live one must NOT be committed
        // ahead of it — counting stops at the first live cell.
        let mut a = app();
        a.cells.push(Cell::Reasoning { text: "think".into(), done: false, seed: 1 }); // live
        a.cells.push(Cell::User("settled".into())); // settled, but behind the live one
        assert_eq!(settled_prefix_len(&a), 0);
        if let Cell::Reasoning { done, .. } = &mut a.cells[0] {
            *done = true;
        }
        assert_eq!(settled_prefix_len(&a), 2);
    }

    #[test]
    fn reasoning_settles_when_done() {
        let mut a = app();
        a.cells.push(Cell::Reasoning { text: "think".into(), done: false, seed: 1 });
        assert_eq!(settled_prefix_len(&a), 0);
        if let Cell::Reasoning { done, .. } = &mut a.cells[0] {
            *done = true;
        }
        assert_eq!(settled_prefix_len(&a), 1);
    }

    #[test]
    fn plan_settles_when_turn_ends() {
        let mut a = app();
        a.running = true;
        a.cells.push(Cell::Plan(vec![("step".into(), "pending".into())]));
        assert_eq!(settled_prefix_len(&a), 0);
        a.running = false;
        assert_eq!(settled_prefix_len(&a), 1);
    }

    #[test]
    fn download_settles_when_done() {
        let mut a = app();
        a.cells.push(Cell::Download { tag: "m".into(), line: "50%".into(), done: false, ok: false });
        assert_eq!(settled_prefix_len(&a), 0);
        if let Cell::Download { done, ok, .. } = &mut a.cells[0] {
            *done = true;
            *ok = true;
        }
        assert_eq!(settled_prefix_len(&a), 1);
    }
}

// Drives `sh -c` fake processes, so the suite is Unix-only (matches the crate's
// existing cfg(unix) practice for process-group tests).
#[cfg(all(test, unix))]
mod stream_child_tests {
    use super::{stream_child, Stream};
    use std::time::Duration;
    use tokio::process::Command;

    fn sh(script: &str) -> Command {
        let mut c = Command::new("sh");
        c.arg("-c").arg(script);
        c
    }

    #[tokio::test]
    async fn forwards_foreground_stdout_lines() {
        let mut lines = Vec::new();
        let ok = stream_child(sh("echo a; echo b; echo c"), Stream::Stdout, |l| lines.push(l.to_string()))
            .await
            .unwrap();
        assert!(ok);
        assert_eq!(lines, ["a", "b", "c"]);
    }

    #[tokio::test]
    async fn forwards_foreground_stderr_lines() {
        let mut lines = Vec::new();
        let ok = stream_child(sh("echo a 1>&2; echo b 1>&2"), Stream::Stderr, |l| lines.push(l.to_string()))
            .await
            .unwrap();
        assert!(ok);
        assert_eq!(lines, ["a", "b"]);
    }

    // Deadlock + busy-spin regression in one test. The drained pipe receives far
    // more than the OS pipe buffer (64KB on macOS): without the background drain
    // the child blocks on write; if the drain loop busy-spun past EOF the awaited
    // drain task would never return. Either failure mode trips the timeout.
    #[tokio::test]
    async fn drains_large_background_pipe_without_deadlock() {
        // 512KB to stdout (drained), one sentinel line to stderr (foreground).
        let script = "yes xxxxxxxxxxxxxxxx | head -c 524288; echo; echo done 1>&2";
        let mut lines = Vec::new();
        let res = tokio::time::timeout(
            Duration::from_secs(10),
            stream_child(sh(script), Stream::Stderr, |l| lines.push(l.to_string())),
        )
        .await
        .expect("stream_child hung — pipe-buffer deadlock or busy-spin regression");
        assert!(res.unwrap());
        assert!(lines.iter().any(|l| l == "done"), "sentinel missing: {lines:?}");
    }

    #[tokio::test]
    async fn reports_exit_status() {
        let ok = stream_child(sh("exit 0"), Stream::Stdout, |_| {}).await.unwrap();
        assert!(ok);
        let ok = stream_child(sh("exit 1"), Stream::Stdout, |_| {}).await.unwrap();
        assert!(!ok);
    }

    // stdin regression: a child that reads stdin must see EOF (stdin is closed),
    // not block forever on the inherited raw-mode TUI terminal.
    #[tokio::test]
    async fn null_stdin_does_not_hang() {
        let mut lines = Vec::new();
        let res = tokio::time::timeout(
            Duration::from_secs(5),
            stream_child(sh("read x; echo proceeded"), Stream::Stdout, |l| lines.push(l.to_string())),
        )
        .await
        .expect("stream_child hung — child blocked on inherited stdin");
        assert!(res.unwrap());
        assert_eq!(lines, ["proceeded"]);
    }

    #[tokio::test]
    async fn spawn_failure_surfaces_as_err() {
        let cmd = Command::new("definitely-not-a-real-binary-xyzzy");
        assert!(stream_child(cmd, Stream::Stdout, |_| {}).await.is_err());
    }
}
