//! Rendering: cells → styled lines, Codex-style header card, lightweight
//! markdown, approval modal, input composer, status footer.

use super::{App, Cell, ExecStatus};

const MAX_CELL_RENDER_LINES: usize = 2048;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style, Stylize};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use std::sync::OnceLock;
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};

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

// Theme matched to the Lucid Train frontend (globals.css):
// accent #1a8fe3 (azure hover — brighter than #0078d4 for dark terminals),
// secondary #6366f1, success #16a34a, warning #ea580c, error #dc2626.
pub const ACCENT: Color = Color::Rgb(0x1a, 0x8f, 0xe3);
pub const SECONDARY: Color = Color::Rgb(0x63, 0x66, 0xf1);
pub const SUCCESS: Color = Color::Rgb(0x16, 0xa3, 0x4a);
pub const WARNING: Color = Color::Rgb(0xea, 0x58, 0x0c);
pub const ERROR: Color = Color::Rgb(0xdc, 0x26, 0x26);
pub const AMBER: Color = Color::Rgb(0xd4, 0xa0, 0x44);

// ---------- terminal-independent color ----------
//
// Every color in this UI is authored as 24-bit RGB so it is immune to the
// terminal's ANSI theme (the user's zsh/iTerm/Terminal color profile). The
// catch: not every terminal speaks truecolor. macOS Terminal.app, older tmux,
// and some SSH sessions only understand the 256-color palette and will
// approximate — or mistheme — raw 24-bit escapes, so the exact same build can
// look completely different from one machine to the next.
//
// To render identically everywhere we detect truecolor once and, when it is
// absent, down-map every RGB color in the finished frame to the nearest
// xterm-256 index in 16..=255. Those indices are fixed across terminals and
// themes (only 0..=15 are theme-controlled, and we never emit those), so the
// result is stable regardless of the user's terminal profile.
//
// Override detection with LUCID_COLOR=truecolor (force 24-bit) or
// LUCID_COLOR=256 (force the palette path, useful to preview the fallback).

fn truecolor_enabled() -> bool {
    static CACHE: OnceLock<bool> = OnceLock::new();
    *CACHE.get_or_init(|| {
        match std::env::var("LUCID_COLOR").ok().as_deref() {
            Some("truecolor") | Some("24bit") | Some("24") => return true,
            Some("256") | Some("8bit") | Some("8") => return false,
            _ => {}
        }
        if let Ok(v) = std::env::var("COLORTERM") {
            let v = v.to_ascii_lowercase();
            if v.contains("truecolor") || v.contains("24bit") {
                return true;
            }
        }
        // Terminals advertising a direct-color terminfo entry (e.g.
        // xterm-direct, tmux-direct) speak truecolor.
        if let Ok(t) = std::env::var("TERM") {
            if t.contains("direct") {
                return true;
            }
        }
        false
    })
}

/// Nearest xterm-256 palette index for a 24-bit color, choosing between the
/// 6x6x6 color cube (16..=231) and the 24-step grayscale ramp (232..=255),
/// whichever is closer. Never returns 0..=15 (the theme-controlled colors), so
/// the rendered color does not depend on the user's terminal palette.
fn nearest_256(r: u8, g: u8, b: u8) -> u8 {
    const STEPS: [u8; 6] = [0, 95, 135, 175, 215, 255];
    fn cube_level(v: u8) -> usize {
        let mut best = 0usize;
        let mut best_d = i32::MAX;
        let mut i = 0;
        while i < STEPS.len() {
            let d = (v as i32 - STEPS[i] as i32).abs();
            if d < best_d {
                best_d = d;
                best = i;
            }
            i += 1;
        }
        best
    }
    let (ri, gi, bi) = (cube_level(r), cube_level(g), cube_level(b));
    let cube = (STEPS[ri], STEPS[gi], STEPS[bi]);
    let cube_idx = 16 + 36 * ri + 6 * gi + bi;

    // Grayscale ramp: indices 232..=255 map to values 8, 18, ..., 238.
    let gray_avg = (r as i32 + g as i32 + b as i32) / 3;
    let gray_step = ((gray_avg - 8).max(0) / 10).min(23);
    let gray_val = (8 + gray_step * 10) as u8;
    let gray_idx = 232 + gray_step as usize;

    let dist = |c: (u8, u8, u8)| -> i32 {
        let dr = r as i32 - c.0 as i32;
        let dg = g as i32 - c.1 as i32;
        let db = b as i32 - c.2 as i32;
        dr * dr + dg * dg + db * db
    };
    if dist((gray_val, gray_val, gray_val)) < dist(cube) {
        gray_idx as u8
    } else {
        cube_idx as u8
    }
}

#[inline]
fn map_color(c: Color) -> Color {
    match c {
        Color::Rgb(r, g, b) => Color::Indexed(nearest_256(r, g, b)),
        other => other,
    }
}

/// When the terminal lacks truecolor, rewrite every RGB color in the finished
/// frame to its nearest fixed 256-palette index, so the UI looks the same on
/// Terminal.app, tmux, and SSH as it does on a truecolor terminal.
fn downsample_colors(f: &mut Frame) {
    if truecolor_enabled() {
        return;
    }
    let buf = f.buffer_mut();
    for cell in buf.content.iter_mut() {
        cell.fg = map_color(cell.fg);
        cell.bg = map_color(cell.bg);
        cell.underline_color = map_color(cell.underline_color);
    }
}

// ---------- intro: deterministic particle scene ----------
//
// A tiny "engine" in the spirit of a three.js scene graph, adapted to cells:
// every star, smoke particle and dust streak is a pure function of the tick
// clock (emission tick → position/age now), rendered directly into the
// ratatui buffer with float→cell projection and color interpolation. No
// state, no allocations between frames, perfectly smooth on skip/resize.

/// Total animation length in ticks (~9s at 80ms/tick); any key skips.
pub const INTRO_LIFE: u32 = 115;

/// Dream-train silhouette: solid dark body with warm glowing windows
/// ('w' markers), label letters glow softly, '●' marks animated wheels.
const LOCO: [&str; 6] = [
    r"      ▗█▖  ▄▄▄                                                  ",
    r"  ▄▄▄▄███▄▄███▄▄▄▄▄▄▄▄▖      ▗▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▖  ",
    r"  ▐████████████████████▌▄▄▄▄▄▐████████████████████████████████▌ ",
    r"  ▐██w███w███w███▌█▌w▐█▌─────▐█w██w██w█ LUCID TRAIN █w██w██w██▌ ",
    r"  ▐████████████████████▌     ▐████████████████████████████████▌ ",
    r"   ▀●▀▀●▀▀▀▀●▀▀▀▀▀▀●▀▀▀       ▀●▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀●▀▀   ",
];
const WHEEL_PHASES: [char; 4] = ['◴', '◷', '◶', '◵'];
const CHIMNEY_X: i32 = 7;

#[inline]
fn prand(seed: u32) -> u32 {
    let mut s = seed.wrapping_mul(1664525).wrapping_add(1013904223);
    s ^= s >> 16;
    s.wrapping_mul(2246822519)
}

#[inline]
fn lerp(a: u8, b: u8, t: f32) -> u8 {
    (a as f32 + (b as f32 - a as f32) * t.clamp(0.0, 1.0)) as u8
}

fn lerp_rgb(from: (u8, u8, u8), to: (u8, u8, u8), t: f32) -> Color {
    Color::Rgb(lerp(from.0, to.0, t), lerp(from.1, to.1, t), lerp(from.2, to.2, t))
}

/// Soft indigo pulse for live elements: one hue, two brightness steps —
/// calm breathing, never a rainbow strobe.
fn pulse(tick: usize) -> Color {
    if (tick / 8) % 2 == 0 {
        SECONDARY
    } else {
        Color::Rgb(0x81, 0x8c, 0xf8)
    }
}

/// One status term is chosen per step (seeded, stable until the step ends —
/// no mid-step rotation). ~100 terms: lucid dreams, galaxies, quantum
/// physics, meditation, DNA/biotech, and Gen-Z energy.
const DREAM_TERMS: &[&str] = &[
    // lucid dreams
    "Dream weaving", "Astral drifting", "Lucid looping", "Dreamscaping",
    "Reality checking", "Dream journaling", "Hypnagogic drifting", "REM surfing",
    "Dream stacking", "Night shifting", "Cloud hopping", "Moonwalking",
    "Stargazing", "Dream seeding", "Mind wandering", "Deja vu debugging",
    "Sleepwalking the code",
    // galaxies & cosmos
    "Galaxy brain loading", "Nebula surfing", "Orbiting the answer",
    "Stardust sifting", "Supernova brewing", "Event horizon peeking",
    "Comet chasing", "Cosmic drifting", "Warp speed thinking",
    "Black hole diving", "Constellation mapping", "Milky Way streaming",
    "Asteroid mining", "Gravity bending", "Pulsar pinging", "Solar flaring",
    "Wormhole routing",
    // quantum physics
    "Quantum tunneling", "Superposition holding", "Entangling qubits",
    "Collapsing wave functions", "Spooky action at a distance",
    "Heisenberg hedging", "Schrodinger compiling", "Quantum leaping",
    "Decoherence dodging", "Planck scale plotting", "Spin flipping",
    "Quantum foaming", "Probability surfing", "Uncertainty embracing",
    "Photon juggling", "Quark mixing", "String theory strumming",
    // meditation
    "Deep breathing", "Third eye opening", "Chakra aligning", "Mantra humming",
    "Zen mode engaged", "Inner peace loading", "Mindful scanning",
    "Aura polishing", "Energy grounding", "Vibe raising", "Soul searching",
    "Lotus posing", "Om chanting", "Kundalini rising", "Stillness seeking",
    "Breathwork flowing", "Monk mode",
    // DNA & biotech
    "Gene splicing", "CRISPR snipping", "Helix unwinding", "Transcribing DNA",
    "Protein folding", "Telomere tuning", "Mitochondria charging",
    "Synapse firing", "Neuron pruning", "Stem cell steeping", "Genome browsing",
    "Ribosome reading", "Enzyme catalyzing", "Epigenetic editing",
    "Plasmid looping", "Peptide bonding", "Codon decoding",
    // gen-z energy
    "Manifesting", "Vibing", "Locked in", "Cooking", "Lowkey computing",
    "Highkey processing", "No cap calculating", "Rizzing the runtime",
    "Main character mode", "Touching grass later", "Slaying the stack",
    "Bussin builds", "It's giving genius", "Understood the assignment",
    "Glow up in progress", "Side quest grinding", "Brain rot filtering",
    "Doom scrolling the docs", "Goblin mode coding", "Delulu is the solulu",
];

/// Stable term for a step: seeded once, constant for the step's lifetime.
fn dream_term(seed: u32) -> &'static str {
    DREAM_TERMS[(prand(seed.wrapping_mul(2654435761)) as usize) % DREAM_TERMS.len()]
}

/// Constellation prefix: several stars + a quantum particle, static indigo
/// shades (no flashing).
fn constellation() -> Vec<Span<'static>> {
    vec![
        Span::styled("✦ ", Style::default().fg(SECONDARY)),
        Span::styled("⋆ ", Style::default().fg(Color::Rgb(0x81, 0x8c, 0xf8))),
        Span::styled("✧ ", Style::default().fg(Color::Rgb(0xa5, 0xb4, 0xfc)).add_modifier(Modifier::DIM)),
        Span::styled("∘ ", Style::default().fg(Color::Rgb(0x6d, 0x70, 0x93)).add_modifier(Modifier::DIM)),
    ]
}

#[inline]
fn put(f: &mut Frame, w: u16, h: u16, x: i32, y: i32, ch: char, color: Color, bold: bool) {
    if x < 0 || y < 0 || x >= w as i32 || y >= h as i32 {
        return;
    }
    // The intro draws in a 0-based coordinate space, but the (inline) viewport
    // buffer can be offset from the screen origin — index relative to the area,
    // not absolute, or rendering lands outside the buffer and panics.
    let origin = f.area();
    let cell = &mut f.buffer_mut()[(origin.x + x as u16, origin.y + y as u16)];
    cell.set_char(ch);
    cell.set_fg(color);
    if bold {
        cell.set_style(Style::default().fg(color).bold());
    }
}

/// Boot-log lines typed out like an agent initializing its subsystems.
/// (label, detail, start tick)
const BOOT: [(&str, &str, u32); 6] = [
    ("core", "AHE evolved harness · publish guard · risk hints", 3),
    ("tools", "shell · web_search · read_url · update_plan · mcp", 12),
    ("context", "repo map · .lucid/knowledge.md · compaction", 21),
    ("skills", "SKILL.md packs · skills.sh registry", 30),
    ("models", "ollama local · openrouter", 39),
    ("engine", "laying tracks…", 48),
];

/// Startup scene: an agent boot log over a lucid dreamscape — aurora
/// ribbons, crescent moon, parallax mountain silhouettes, and the dream
/// train (dark body, warm lit windows) arriving with volumetric rainbow
/// smoke and dust winds. Every particle is a pure function of the tick.
fn draw_intro(f: &mut Frame, app: &App, ticks: u32) {
    let area = f.area();
    let (w, h) = (area.width, area.height);
    if w < 20 || h < 12 {
        return;
    }
    let t = ticks as f32;
    let train_w = LOCO[0].chars().count() as i32;
    let rail_y = h as i32 - 3;
    let body_top = rail_y - 6;
    let dim_grey = Color::Rgb(0x71, 0x71, 0x7a);
    let log_bottom = 3 + BOOT.len() as i32 + 2;

    // Ease-out arrival: enter at speed, settle just left of center.
    let target = (w as i32 - train_w) / 2;
    let start = -(train_w + 4);
    let train_x = target - ((target - start) as f32 * (-t / 16.0).exp()) as i32;
    let speed = ((target - start) as f32 / 16.0) * (-t / 16.0).exp();

    // ---- starfield (whole sky, parallax + twinkle) ----
    for i in 0..((w as u32 / 2).min(70)) {
        let r = prand(i * 7919);
        let layer = 1 + (r % 3) as i32;
        let x0 = (r % w as u32) as i32;
        let y = 1 + (prand(r) % (body_top.max(2) as u32 - 1)) as i32;
        let x = (x0 - (ticks as i32 * layer) / 6).rem_euclid(w as i32);
        let twinkle = (prand(i * 31 + ticks / 4) % 6) as usize;
        let glyph = ['·', '✦', '˚', '⋆', '·', '.'][twinkle];
        let shade = 96 + (layer * 30) as u8;
        put(f, w, h, x, y, glyph, Color::Rgb(shade - 26, shade - 16, shade.saturating_add(34)), false);
    }

    // ---- aurora ribbons drifting through the upper sky ----
    let aurora_top = log_bottom.max(8);
    let aurora_bot = (aurora_top + (body_top - 3 - aurora_top).max(0) / 2).max(aurora_top);
    for y in aurora_top..aurora_bot {
        let fy = y as f32;
        for x in 0..w as i32 {
            let fx = x as f32;
            let v = (fx * 0.062 + t * 0.055 + fy * 0.45).sin()
                + (fx * 0.017 - t * 0.034 + fy * 0.9).sin();
            if v > 1.25 {
                let hue = ((fx * 0.02 + t * 0.02 + fy * 0.22).sin() + 1.0) / 2.0;
                let glyph = if v > 1.7 { '▒' } else { '░' };
                // Deep teal ↔ violet, kept dark so it glows without shouting.
                put(f, w, h, x, y, glyph, lerp_rgb((0x12, 0x4e, 0x5c), (0x39, 0x2d, 0x6e), hue), false);
            }
        }
    }

    // ---- crescent moon with halo (top right, clear of the log) ----
    let (mcx, mcy) = (w as i32 - 15, (log_bottom - 3).max(6));
    for dy in -5..=5i32 {
        for dx in -10..=10i32 {
            let fx = dx as f32 * 0.55;
            let fy = dy as f32;
            let d = (fx * fx + fy * fy).sqrt();
            let ox = fx + 2.3;
            let od = (ox * ox + fy * fy).sqrt();
            let (x, y) = (mcx + dx, mcy + dy);
            if d <= 4.0 && od > 3.0 {
                put(f, w, h, x, y, '▓', Color::Rgb(0xd9, 0xd5, 0xc2), false);
            } else if d <= 4.7 && od > 3.6 {
                put(f, w, h, x, y, '░', Color::Rgb(0x55, 0x54, 0x60), false);
            }
        }
    }

    // ---- parallax mountain silhouettes on the horizon ----
    for x in 0..w as i32 {
        let fx = x as f32;
        let far = 2.6 + 1.9 * ((fx * 0.043 + 1.0).sin() + 0.5 * (fx * 0.105 + 1.7).sin());
        for dy in 0..far.max(0.0) as i32 {
            put(f, w, h, x, rail_y - 1 - dy, '█', Color::Rgb(0x14, 0x17, 0x23), false);
        }
        let near = 1.0 + 1.5 * ((fx * 0.066 + 4.2).sin() + 0.6 * (fx * 0.15).sin());
        for dy in 0..near.max(0.0) as i32 {
            put(f, w, h, x, rail_y - 1 - dy, '█', Color::Rgb(0x0c, 0x0e, 0x16), false);
        }
    }

    // ---- rails, scrolling sleepers, dark ground ----
    let scroll = (t * 2.0 + speed * 4.0) as i32;
    for x in 0..w as i32 {
        let ch = if (x + scroll) % 7 == 0 { '╪' } else { '═' };
        put(f, w, h, x, rail_y, ch, Color::Rgb(0x59, 0x5f, 0x6e), false);
        for gy in (rail_y + 1)..h as i32 {
            let r = prand((x as u32) * 311 + gy as u32 * 73);
            let ch = if r % 13 == 0 { '\'' } else if r % 17 == 0 { '.' } else { ' ' };
            if ch != ' ' {
                put(f, w, h, x, gy, ch, Color::Rgb(0x23, 0x26, 0x30), false);
            }
        }
    }

    // ---- the dream train: dark silhouette, warm lit windows ----
    let flicker = |seed: f32| 0.82 + 0.18 * (t * 0.5 + seed).sin();
    for (row, art) in LOCO.iter().enumerate() {
        let y = body_top + row as i32;
        let chars: Vec<char> = art.chars().collect();
        for (col, &orig) in chars.iter().enumerate() {
            let x = train_x + col as i32;
            // Interior 1-2 cell space runs (nameplate gaps, window spacing)
            // become solid body so the backdrop never bleeds through; longer
            // runs (engine↔coach gap, open sky) stay transparent.
            let ch = if orig == ' ' {
                let left_run = chars[..col].iter().rev().take_while(|&&c| c == ' ').count();
                let right_run = chars[col + 1..].iter().take_while(|&&c| c == ' ').count();
                let has_left = chars[..col].iter().any(|&c| c != ' ');
                let has_right = chars[col + 1..].iter().any(|&c| c != ' ');
                if left_run + right_run + 1 <= 2 && has_left && has_right {
                    '█'
                } else {
                    ' '
                }
            } else {
                orig
            };
            match ch {
                ' ' => {}
                'w' => {
                    // Warm window glow, gently flickering like carriage lamps.
                    let b = flicker(col as f32 * 1.3);
                    put(f, w, h, x, y, '▮',
                        Color::Rgb((0xff as f32 * b) as u8, (0xd2 as f32 * b) as u8, (0x8a as f32 * b) as u8), true);
                }
                '●' => {
                    let phase = WHEEL_PHASES[((ticks + col as u32) % 4) as usize];
                    put(f, w, h, x, y, phase, Color::Rgb(0x8b, 0x93, 0xa5), false);
                }
                '─' => put(f, w, h, x, y, '─', Color::Rgb(0x4a, 0x4f, 0x5e), false),
                c if c.is_ascii_alphabetic() => {
                    // Glowing nameplate letters.
                    put(f, w, h, x, y, c, Color::Rgb(0xc7, 0xd2, 0xff), true);
                }
                c => {
                    // Body silhouette: barely-blue charcoal with row shading.
                    let g = row as f32 / (LOCO.len() - 1) as f32;
                    put(f, w, h, x, y, c, lerp_rgb((0x32, 0x37, 0x52), (0x22, 0x25, 0x38), g), false);
                }
            }
        }
    }

    // ---- volumetric smoke: 3 jittered particles per emission, billowing ----
    for e in (0..ticks).rev().take(56) {
        let age = (ticks - e) as f32;
        if age > 28.0 {
            break;
        }
        let ex_base = target as f32 - (target - start) as f32 * (-(e as f32) / 16.0).exp() + CHIMNEY_X as f32;
        let life = age / 28.0;
        for p in 0..3u32 {
            let r = prand(e.wrapping_mul(2654435761).wrapping_add(p * 97));
            let jx = ((r % 100) as f32 / 100.0 - 0.5) * (1.5 + age * 0.30);
            let jy = (((r >> 8) % 100) as f32 / 100.0 - 0.5) * (0.8 + age * 0.16);
            let sway = ((age * 0.5) + (r % 7) as f32).sin() * 1.4;
            let px = ex_base - age * 0.62 + sway + jx;
            let py = (body_top - 1) as f32 - age * 0.38 + jy;
            if (py as i32) <= log_bottom {
                continue;
            }
            let glyph = match (life * 5.0) as u32 {
                0 => '▓',
                1 => '▒',
                2 => '░',
                3 => '∘',
                _ => '·',
            };
            let color = lerp_rgb((0x81, 0x8c, 0xf8), (0x4a, 0x4d, 0x60), (life * life).min(1.0));
            put(f, w, h, px as i32, py as i32, glyph, color, false);
        }
    }

    // ---- dust wind off the wheels while moving ----
    if speed > 0.15 {
        for e in (0..ticks).rev().take(24) {
            let age = (ticks - e) as f32;
            if age > 9.0 {
                break;
            }
            let r = prand(e * 40503 + 7);
            let ex = target as f32 - (target - start) as f32 * (-(e as f32) / 16.0).exp()
                + (r % 40) as f32 + 4.0;
            let px = ex - age * (2.6 + (r % 3) as f32 * 0.7);
            let py = rail_y as f32 + ((r >> 6) % 2) as f32;
            let glyph = match (age / 3.0) as u32 {
                0 => '≋',
                1 => '~',
                _ => '⋅',
            };
            let color = lerp_rgb((0x9a, 0x8d, 0x78), (0x2c, 0x2e, 0x38), age / 9.0);
            put(f, w, h, px as i32, py as i32, glyph, color, false);
        }
    }

    // ---- boot log on top of the scene (crisp, never painted over) ----
    let title = ">_ lucid-train";
    for (i, ch) in title.chars().enumerate() {
        let color = if i < 2 { ACCENT } else { Color::Rgb(0xe4, 0xe4, 0xe7) };
        put(f, w, h, 2 + i as i32, 1, ch, color, true);
    }
    for (i, ch) in " · agentic coding harness".chars().enumerate() {
        put(f, w, h, 2 + title.len() as i32 + i as i32, 1, ch, dim_grey, false);
    }
    for (li, (label, detail, lstart)) in BOOT.iter().enumerate() {
        if ticks < *lstart {
            break;
        }
        let y = 3 + li as i32;
        let typed = ((ticks - lstart) * 3) as usize;
        let done = typed >= detail.chars().count();
        if done && li < BOOT.len() - 1 {
            put(f, w, h, 2, y, '✓', SUCCESS, true);
        } else if done {
            put(f, w, h, 2, y, '◉', pulse(ticks as usize), true);
        } else {
            let sp = SPINNER[(ticks as usize + li) % SPINNER.len()].chars().next().unwrap_or('⠋');
            put(f, w, h, 2, y, sp, ACCENT, false);
        }
        for (i, ch) in label.chars().enumerate() {
            put(f, w, h, 4 + i as i32, y, ch, ACCENT, false);
        }
        for (i, ch) in detail.chars().take(typed).enumerate() {
            put(f, w, h, 13 + i as i32, y, ch, dim_grey, false);
        }
        if !done {
            put(f, w, h, 13 + typed.min(detail.chars().count()) as i32, y, '▌', Color::Rgb(0xe4, 0xe4, 0xe7), false);
        }
    }
    if ticks > 62 {
        let msg = "ready — departing now · press any key";
        for (i, ch) in msg.chars().enumerate() {
            let color = if i < 5 { SUCCESS } else { dim_grey };
            put(f, w, h, 2 + i as i32, 3 + BOOT.len() as i32 + 1, ch, color, i < 5);
        }
    }

    let _ = app;
}

pub fn draw(f: &mut Frame, app: &mut App) {
    draw_scene(f, app);
    // Final pass: make the palette terminal-independent so the UI looks the
    // same on every machine regardless of its truecolor support or theme.
    downsample_colors(f);
}

fn draw_scene(f: &mut Frame, app: &mut App) {
    if let Some(ticks) = app.intro_ticks {
        draw_intro(f, app, ticks);
        return;
    }
    if app.onboarding.is_some() {
        draw_onboarding(f, app);
        return;
    }
    let area = f.area();
    let approval_h = if app.approval.is_some() { 6 } else { 0 };
    let sugg_h = if app.approval.is_none() && !app.suggestions.is_empty() {
        (app.suggestions.len().min(6) + 1) as u16
    } else {
        0
    };
    let chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            // Min(0), not Min(3): in the small inline viewport a 3-row floor here
            // would steal rows from the input's Length(3) and collapse the box.
            Constraint::Min(0),             // history (live region)
            Constraint::Length(approval_h), // approval modal
            Constraint::Length(sugg_h),     // autocomplete popup
            Constraint::Length(3),          // input
            Constraint::Length(1),          // status
        ])
        .split(area);

    draw_live_region(f, app, chunks[0]);
    if app.approval.is_some() {
        draw_approval(f, app, chunks[1]);
    }
    if sugg_h > 0 {
        draw_suggestions(f, app, chunks[2]);
    }
    draw_input(f, app, chunks[3]);
    draw_status(f, app, chunks[4]);
}

fn draw_suggestions(f: &mut Frame, app: &App, area: Rect) {
    let dim = Style::default().add_modifier(Modifier::DIM);
    let mut lines: Vec<Line> = Vec::new();
    let visible = app.suggestions.len().min(6);
    // Keep the selected row in view.
    let start = app.sugg_idx.saturating_sub(visible.saturating_sub(1));
    for (i, s) in app.suggestions.iter().enumerate().skip(start).take(visible) {
        let selected = i == app.sugg_idx;
        let marker = if selected { "▸ " } else { "  " };
        let label_style = if selected {
            Style::default().fg(ACCENT).bold()
        } else {
            Style::default().fg(ACCENT)
        };
        lines.push(Line::from(vec![
            Span::styled(marker, label_style),
            Span::styled(format!("{:<24}", s.label), label_style),
            Span::styled(s.detail.clone(), dim),
        ]));
    }
    lines.push(Line::from(Span::styled(
        "  Tab to complete • ↑/↓ to choose",
        dim,
    )));
    f.render_widget(Paragraph::new(lines), area);
}

/// Render one cell plus the inter-cell spacer into `out`. This is the unit both
/// the live viewport and the scrollback-commit path render, so spacing stays
/// identical between what's on screen and what lands in the terminal scrollback.
pub fn render_cell_block(cell: &Cell, app: &App, width: usize, out: &mut Vec<Line<'static>>) {
    render_cell(cell, app, width, out);
    out.push(Line::default()); // spacer between cells
}

/// The animated "working" line: indigo smoke drifting off a tiny train, one
/// stable dream-term for the whole turn. It lives only in the live viewport and
/// is never inserted into scrollback.
fn working_line(app: &App) -> Line<'static> {
    let tk = app.spinner_idx;
    let mut spans: Vec<Span> = Vec::new();
    let smoke_glyphs = ['·', '∘', '○', '◌', '○', '∘'];
    // Indigo gradient by position (dim far from the engine), glyphs drift for
    // motion — color never flashes.
    let smoke_shades = [
        Color::Rgb(0x3f, 0x42, 0x66),
        Color::Rgb(0x4c, 0x50, 0x82),
        Color::Rgb(0x5a, 0x5e, 0xa8),
        Color::Rgb(0x63, 0x66, 0xf1),
        Color::Rgb(0x81, 0x8c, 0xf8),
        Color::Rgb(0xa5, 0xb4, 0xfc),
    ];
    for i in 0..6usize {
        let g = smoke_glyphs[(tk / 2 + i) % smoke_glyphs.len()];
        spans.push(Span::styled(g.to_string(), Style::default().fg(smoke_shades[i])));
    }
    spans.push(Span::styled(" ▟█▛", Style::default().fg(SECONDARY).bold()));
    spans.push(Span::styled(
        WHEEL_PHASES[tk % WHEEL_PHASES.len()].to_string(),
        Style::default().fg(Color::Rgb(0x8b, 0x93, 0xa5)),
    ));
    // Seeded by turn number: constant until this turn finishes.
    let turn = app.cells.iter().filter(|c| matches!(c, Cell::User(_))).count() as u32;
    let term = dream_term(turn);
    let dots = ".".repeat(1 + (tk / 12) % 3);
    spans.push(Span::styled(
        format!(" {term}{dots} "),
        Style::default().fg(ACCENT).italic(),
    ));
    spans.push(Span::styled(
        "(Esc to interrupt)",
        Style::default().add_modifier(Modifier::DIM),
    ));
    Line::from(spans)
}

/// Build the lines for the live region: every cell currently in `app.cells`
/// (i.e. not yet committed to scrollback), followed by the working line while a
/// turn is running. No scroll math — the caller bottom-anchors / tail-clips it.
pub fn live_lines(app: &App, width: usize) -> Vec<Line<'static>> {
    let mut lines: Vec<Line> = Vec::new();
    for cell in &app.cells {
        render_cell_block(cell, app, width, &mut lines);
    }
    // Drop the spacer after the last rendered cell.
    if lines.last() == Some(&Line::default()) {
        lines.pop();
    }
    if app.running {
        lines.push(working_line(app));
    }
    lines
}

/// Draw the live region (cells not yet committed to scrollback) bottom-anchored
/// into `area`, tail-clipping to the newest rows that fit. There is no scroll
/// offset — committed history lives in the terminal's own scrollback.
fn draw_live_region(f: &mut Frame, app: &App, area: Rect) {
    let width = area.width.saturating_sub(2).max(20) as usize;
    let mut lines = live_lines(app, width);
    let h = area.height as usize;
    if lines.len() > h {
        lines.drain(0..lines.len() - h);
    }
    f.render_widget(Paragraph::new(Text::from(lines)), area);
}

/// Replace Unicode replacement characters (U+FFFD) and other glyphs that
/// terminal emulators sometimes render as diamonds/spaceholders. Invalid UTF-8
/// sequences that reach the UI are shown as a visible marker so token parsing
/// problems don't look like a corrupted model output.
///
/// Also strips ANSI escape sequences and normalizes private-use / layout
/// control characters so model output (which may contain colour codes or
/// unusual token boundaries) renders cleanly in the TUI.
pub fn sanitize_display(s: &str) -> String {
    let mut out = String::with_capacity(s.len());
    let mut chars = s.chars().peekable();
    while let Some(ch) = chars.next() {
        match ch {
            // ANSI escape sequences.
            '\u{1B}' => {
                match chars.peek() {
                    // CSI: ESC [ ... final byte (e.g. ESC [ 31 m).
                    Some(&'[') => {
                        chars.next();
                        while let Some(&c) = chars.peek() {
                            chars.next();
                            if c.is_ascii_alphabetic() || c == '~' {
                                break;
                            }
                        }
                    }
                    // OSC: ESC ] ... terminated by BEL (\a) or ST (ESC \). Used by
                    // hyperlinks (ESC ]8;;URL...) — without consuming the payload
                    // here, the URL leaks into the transcript as literal text.
                    Some(&']') => {
                        chars.next();
                        while let Some(&c) = chars.peek() {
                            chars.next();
                            if c == '\u{07}' {
                                break;
                            }
                            if c == '\u{1B}' {
                                if chars.peek() == Some(&'\\') {
                                    chars.next();
                                }
                                break;
                            }
                        }
                    }
                    // Lone ESC or other forms: drop just the ESC (as before).
                    _ => {}
                }
                continue;
            }
            // Unicode replacement char → visible marker so it doesn't look
            // like a corrupted glyph.
            '\u{FFFD}' => out.push('�'),
            // Strip zero-width and other layout-breaking control characters.
            '\u{200B}'..='\u{200D}' | '\u{FEFF}' => {}
            // Private-use code points often show as diamonds; replace with a
            // neutral placeholder.
            c if (('\u{E000}'..='\u{F8FF}').contains(&c)
                || ('\u{F0000}'..='\u{FFFFD}').contains(&c)
                || ('\u{100000}'..='\u{10FFFD}').contains(&c)) =>
            {
                out.push('·');
            }
            // Keep newline/tab; map other C0/C1 controls to a single space.
            c if c.is_control() && c != '\n' && c != '\t' => out.push(' '),
            c => out.push(c),
        }
    }
    out
}

/// Truncate a string so its *display* width does not exceed `max` terminal
/// cells. This handles CJK / emoji widths correctly and avoids cutting in the
/// middle of a multi-cell grapheme.
pub fn truncate_display(s: &str, max: usize) -> String {
    let max = max.max(1);
    let mut out = String::with_capacity(s.len().min(max * 4));
    let mut w = 0usize;
    for ch in s.chars() {
        let cw = ch.width().unwrap_or(0);
        if w + cw > max {
            break;
        }
        out.push(ch);
        // Advance by the char's true display width. Zero-width combining marks
        // (e.g. the accent in a decomposed "é") must add 0, not 1 — counting
        // them as a cell over-measures the string and truncates/wraps early.
        w += cw;
    }
    out
}

/// Width-aware line wrapper. Unlike a naive byte split, it respects wide
/// characters (CJK, emoji) and combining marks, and still runs fast enough for
/// streaming UI updates.
fn wrap(text: &str, width: usize) -> Vec<String> {
    let width = width.max(1);
    let mut lines: Vec<String> = Vec::new();
    let mut current = String::with_capacity(width * 2);
    let mut col = 0usize;

    for line in text.split('\n') {
        for word in line.split_whitespace() {
            let word_width = display_len(word);
            if !current.is_empty() && col + 1 + word_width > width {
                lines.push(current);
                current = String::with_capacity(width * 2);
                col = 0;
            }
            if !current.is_empty() {
                current.push(' ');
                col += 1;
            }
            for ch in word.chars() {
                let cw = ch.width().unwrap_or(0);
                if col + cw > width {
                    lines.push(current);
                    current = String::with_capacity(width * 2);
                    col = 0;
                }
                current.push(ch);
                // True display width: zero-width combining marks add 0 (they
                // stack on the previous glyph), not 1 — else long accented or
                // decomposed text wraps a column or more too early.
                col += cw;
            }
        }
        if current.is_empty() {
            lines.push(String::new());
        } else {
            lines.push(current);
            current = String::with_capacity(width * 2);
            col = 0;
        }
    }
    if !current.is_empty() {
        lines.push(current);
    }
    // Preserve at least an empty string for empty input so callers that expect
    // one line per call don't vanish.
    if lines.is_empty() {
        lines.push(String::new());
    }
    lines
}

fn display_len(s: &str) -> usize {
    s.width()
}

/// Plain-text rendering of a cell for the rolling `/copy all` transcript.
pub fn cell_to_plain(cell: &Cell) -> String {
    match cell {
        Cell::User(t) => format!("› {t}"),
        Cell::Assistant(t) => t.clone(),
        Cell::Reasoning { text, .. } => format!("[thinking] {text}"),
        Cell::Exec { command, output_tail, .. } => format!("$ {command}\n{output_tail}"),
        Cell::Notice(t) => format!("[notice] {t}"),
        Cell::Error(t) => format!("[error] {t}"),
        Cell::Plan(steps) => steps.iter().map(|(s, st)| format!("  - {s} [{st}]")).collect::<Vec<_>>().join("\n"),
        Cell::Diff { path, op, added, removed, .. } => format!("{op} {path} (+{added} -{removed})"),
        Cell::Download { tag, line, .. } => format!("[download {tag}] {line}"),
        _ => String::new(),
    }
}

fn render_cell(cell: &Cell, app: &App, width: usize, out: &mut Vec<Line>) {
    match cell {
        Cell::Header => render_header(app, width, out),
        Cell::Help => render_help(out),
        Cell::Setup => render_setup(app, out),
        Cell::Download { tag, line, done, ok } => {
            let bullet = if !*done {
                // Active downloads pulse rainbow like the engine's lamp.
                Span::styled("◉", Style::default().fg(pulse(app.spinner_idx)).bold())
            } else if *ok {
                Span::styled("•", Style::default().fg(SUCCESS).bold())
            } else {
                Span::styled("•", Style::default().fg(ERROR).bold())
            };
            out.push(Line::from(vec![
                bullet,
                Span::raw(" "),
                Span::styled(
                    if *done { "Downloaded" } else { "Downloading" },
                    Style::default().bold(),
                ),
                Span::raw(" "),
                Span::styled(tag.clone(), Style::default().fg(AMBER)),
            ]));
            out.push(Line::from(Span::styled(
                format!("  └ {}", first_line(line, width.saturating_sub(4))),
                Style::default().add_modifier(Modifier::DIM),
            )));
        }
        Cell::User(text) => {
            for (i, l) in wrap(text, width.saturating_sub(2)).into_iter().enumerate() {
                let prefix = if i == 0 { "› " } else { "  " };
                out.push(Line::from(vec![
                    Span::styled(prefix, Style::default().fg(ACCENT).bold()),
                    Span::styled(l, Style::default().bold()),
                ]));
            }
        }
        Cell::Assistant(text) => render_markdown(text, width, out),
        Cell::Diff { path, op, added, removed, lines } => {
            use crate::tools::edit::DiffKind;
            // Header: ● Update(path)
            out.push(Line::from(vec![
                Span::styled("● ", Style::default().fg(SUCCESS).bold()),
                Span::styled(op.to_string(), Style::default().bold()),
                Span::styled(format!("({path})"), Style::default().fg(ACCENT).bold()),
            ]));
            // Summary: └ Added N lines, removed M
            let mut summary = format!("Added {added} line{}", if *added == 1 { "" } else { "s" });
            if *removed > 0 {
                summary.push_str(&format!(", removed {removed}"));
            }
            out.push(Line::from(vec![
                Span::styled("  └ ", Style::default().add_modifier(Modifier::DIM)),
                Span::styled(summary, Style::default().add_modifier(Modifier::DIM)),
            ]));
            // Diff body with gutter line numbers and +/- coloring.
            let gutter = Style::default().fg(Color::Rgb(0x55, 0x58, 0x63));
            let added_st = Style::default().fg(Color::Rgb(0x86, 0xef, 0xac));
            let removed_st = Style::default().fg(Color::Rgb(0xfc, 0xa5, 0xa5));
            let ctx_st = Style::default().add_modifier(Modifier::DIM);
            for dl in lines.iter().take(60) {
                let (no, sign, st) = match dl.kind {
                    DiffKind::Added => (dl.new_no, "+", added_st),
                    DiffKind::Removed => (dl.old_no, "-", removed_st),
                    DiffKind::Context => (dl.new_no, " ", ctx_st),
                };
                let num = no.map(|n| format!("{n:>5}")).unwrap_or_else(|| "     ".into());
                let body = truncate_display(&dl.text, width.saturating_sub(10));
                out.push(Line::from(vec![
                    Span::styled(format!("  {num} "), gutter),
                    Span::styled(format!("{sign} "), st),
                    Span::styled(body, st),
                ]));
            }
            if lines.len() > 60 {
                out.push(Line::from(Span::styled(
                    format!("       … {} more diff lines", lines.len() - 60),
                    ctx_st,
                )));
            }
        }
        Cell::Plan(steps) => {
            out.push(Line::from(Span::styled("  Plan", Style::default().bold())));
            for (step, status) in steps {
                let (mark, style) = match status.as_str() {
                    "completed" => (
                        Span::styled("  ✓ ", Style::default().fg(SUCCESS)),
                        Style::default().add_modifier(Modifier::DIM | Modifier::CROSSED_OUT),
                    ),
                    "in_progress" => (
                        // The active step glows through the rainbow.
                        Span::styled("  → ", Style::default().fg(pulse(app.spinner_idx)).bold()),
                        Style::default().bold(),
                    ),
                    _ => (
                        Span::styled("  ○ ", Style::default().add_modifier(Modifier::DIM)),
                        Style::default(),
                    ),
                };
                let body = truncate_display(step, width.saturating_sub(4));
                out.push(Line::from(vec![mark, Span::styled(body, style)]));
            }
        }
        Cell::Reasoning { text, done, seed } => {
            let dim_it = Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC);
            if *done {
                // Collapsed: a one-line trace that the dream happened.
                let words = text.split_whitespace().count();
                let first: String = text
                    .lines()
                    .find(|l| !l.trim().is_empty())
                    .unwrap_or("")
                    .chars()
                    .take(width.saturating_sub(24))
                    .collect();
                out.push(Line::from(vec![
                    Span::styled("✦ ⋆ ", Style::default().fg(SECONDARY)),
                    Span::styled(format!("dreamt ({words} words) "), Style::default().add_modifier(Modifier::DIM)),
                    Span::styled(first, dim_it),
                ]));
            } else {
                // Live: a constellation + one stable dream-term in calm
                // indigo (chosen once for this cell, never rotates).
                let tk = app.spinner_idx;
                let mut header: Vec<Span> = constellation();
                header.push(Span::styled(
                    dream_term(*seed).to_string(),
                    Style::default().fg(SECONDARY).italic(),
                ));
                header.push(Span::styled(
                    ".".repeat(1 + (tk / 12) % 3),
                    Style::default().fg(SECONDARY).add_modifier(Modifier::DIM),
                ));
                out.push(Line::from(header));
                let tail: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).rev().take(5).collect();
                for l in tail.into_iter().rev() {
                    for w in wrap(l, width.saturating_sub(2)) {
                        out.push(Line::from(Span::styled(format!("  ▏{w}"), dim_it)));
                    }
                }
            }
        }
        Cell::Exec { command, description, output_tail, status, duration_ms, background, .. } => {
            let running = matches!(status, ExecStatus::Running);
            let file_op = detect_file_op(command);
            let verb_str: String = match (&file_op, running, *background) {
                (_, true, true) => "Running (bg)".into(),
                (Some((verb, file)), false, _) => format!("{} {file}", past_tense(verb)),
                (Some((verb, file)), true, _) => format!("{verb} {file}"),
                (None, true, false) => "Running".into(),
                (None, false, _) => "Ran".into(),
            };
            let (bullet, verb) = match status {
                // Running tools pulse through the lucid rainbow.
                ExecStatus::Running => (
                    Span::styled("◉", Style::default().fg(pulse(app.spinner_idx)).bold()),
                    verb_str,
                ),
                ExecStatus::Ok => (Span::styled("•", Style::default().fg(SUCCESS).bold()), verb_str),
                ExecStatus::Failed(_) => (Span::styled("•", Style::default().fg(ERROR).bold()), verb_str),
            };
            let mut spans = vec![
                bullet,
                Span::raw(" "),
                Span::styled(verb.clone(), Style::default().bold()),
                Span::raw(" "),
            ];
            if file_op.is_some() {
                // Verb already names the file — show the command dimmed.
                spans.push(Span::styled(
                    first_line(command, width.saturating_sub(verb.len() + 12)),
                    Style::default().add_modifier(Modifier::DIM),
                ));
            } else {
                spans.push(Span::styled(
                    first_line(command, width.saturating_sub(verb.len() + 12)),
                    Style::default().fg(AMBER),
                ));
            }
            if *duration_ms > 0 {
                spans.push(Span::styled(
                    format!(" • {:.1}s", *duration_ms as f64 / 1000.0),
                    Style::default().add_modifier(Modifier::DIM),
                ));
            }
            if let ExecStatus::Failed(code) = status {
                spans.push(Span::styled(
                    format!(" • exit {code}"),
                    Style::default().fg(ERROR),
                ));
            }
            out.push(Line::from(spans));
            if !description.is_empty() {
                out.push(Line::from(Span::styled(
                    format!("  ({})", first_line(description, width.saturating_sub(4))),
                    Style::default().add_modifier(Modifier::DIM | Modifier::ITALIC),
                )));
            }
            for (i, l) in output_tail.lines().enumerate() {
                let prefix = if i == 0 { "  └ " } else { "    " };
                let body = truncate_display(l, width.saturating_sub(4));
                out.push(Line::from(Span::styled(
                    format!("{prefix}{body}"),
                    Style::default().add_modifier(Modifier::DIM),
                )));
            }
        }
        Cell::Notice(text) => {
            for l in text.lines() {
                for w in wrap(l, width.saturating_sub(2)) {
                    out.push(Line::from(Span::styled(
                        format!("◦ {w}"),
                        Style::default().add_modifier(Modifier::DIM),
                    )));
                }
            }
        }
        Cell::Error(text) => {
            for w in wrap(text, width.saturating_sub(7)) {
                out.push(Line::from(vec![
                    Span::styled("error ", Style::default().fg(ERROR).bold()),
                    Span::styled(w, Style::default().fg(ERROR)),
                ]));
            }
        }
    }
}

fn render_header(app: &App, width: usize, out: &mut Vec<Line>) {
    let inner = width.clamp(24, 64);
    let border = Style::default().add_modifier(Modifier::DIM);
    let bar = "─".repeat(inner);
    out.push(Line::from(Span::styled(format!("╭{bar}╮"), border)));
    let row = |spans: Vec<Span<'static>>, out: &mut Vec<Line>| {
        let used: usize = spans.iter().map(|s| display_len(s.content.as_ref())).sum();
        let pad = inner.saturating_sub(used + 1);
        let mut v = vec![Span::styled("│ ", border)];
        v.extend(spans);
        v.push(Span::raw(" ".repeat(pad)));
        v.push(Span::styled("│", border));
        out.push(Line::from(v));
    };
    row(
        vec![
            Span::styled(">_ ", border),
            Span::styled("Lucid Train", Style::default().bold()),
            Span::styled(format!(" (v{})", env!("CARGO_PKG_VERSION")), border),
        ],
        out,
    );
    row(vec![Span::raw("")], out);
    row(
        vec![
            Span::styled("model:     ", border),
            Span::styled(app.cfg.model_alias.clone(), Style::default().fg(ACCENT)),
            Span::styled("   ", border),
            Span::styled("/model", Style::default().fg(ACCENT)),
            Span::styled(" to change", border),
        ],
        out,
    );
    row(
        vec![
            Span::styled("provider:  ", border),
            Span::raw(app.cfg.provider.label().to_string()),
        ],
        out,
    );
    row(
        vec![
            Span::styled("directory: ", border),
            Span::raw(shorten_path(&app.cfg.cwd.display().to_string(), inner.saturating_sub(12))),
        ],
        out,
    );
    out.push(Line::from(Span::styled(format!("╰{bar}╯"), border)));
}

fn render_help(out: &mut Vec<Line>) {
    let dim = Style::default().add_modifier(Modifier::DIM);
    out.push(Line::from(Span::styled(
        "  To get started, describe a task or try one of these commands:",
        dim,
    )));
    out.push(Line::from(Span::styled(
        "  New here? /login to run frontier cloud models — no API keys, billed from your plan.",
        dim,
    )));
    out.push(Line::default());
    for (cmd, desc) in [
        ("/login", "sign in (Google) → cloud models, no API keys, paid from your plan"),
        ("/agent", "agent mode — full tools (default)"),
        ("/plan", "plan mode — propose only, nothing executes"),
        ("/research", "research mode — web-only, cited answers"),
        ("/setup", "hardware check + model catalog (local & OpenRouter)"),
        ("/download", "pull a local model via Ollama (e.g. /download qwen3:4b)"),
        ("/delete", "remove a downloaded local model"),
        ("/model", "switch model (/model qwen3:4b, /model kimi-k2.5, /model claude-sonnet)"),
        ("/skills", "list SKILL.md packs available to the agent"),
        ("/mcp", "list/add/remove MCP servers (tools become mcp__server__tool)"),
        ("/apikey", "save your OpenRouter key (Kimi, Claude, GPT, Gemini…)"),
        ("/provider", "openrouter | ollama | openai | <self-hosted url>"),
        ("/approvals", "plan | auto | full-auto — control auto-accepted edits"),
        ("/attach", "attach an image to your next message (vision models)"),
        ("/copy", "copy last reply / output / all to clipboard"),
        ("/update", "check for a new version (on | off to toggle auto-check)"),
        ("/status", "session configuration and context usage"),
        ("/compact", "compact conversation context now"),
        ("/new", "start a fresh session"),
        ("/quit", "exit"),
    ] {
        out.push(Line::from(vec![
            Span::raw("  "),
            Span::styled(format!("{cmd:<11}"), Style::default().fg(ACCENT)),
            Span::styled(format!(" {desc}"), dim),
        ]));
    }
}

/// Onboarding: detected hardware + which models fit on this machine.
fn render_setup(app: &App, out: &mut Vec<Line>) {
    use crate::hardware::{CLOUD_MODELS, LOCAL_MODELS};
    let dim = Style::default().add_modifier(Modifier::DIM);
    let budget = app.hardware.budget_gb();

    out.push(Line::from(vec![
        Span::styled("  Welcome! ", Style::default().bold()),
        Span::styled("No setup detected — pick how you want to run models.", dim),
    ]));
    out.push(Line::default());
    out.push(Line::from(vec![
        Span::styled("  this machine: ", dim),
        if app.hardware_detected {
            Span::styled(app.hardware.summary(), Style::default().fg(ACCENT))
        } else {
            Span::styled("detecting hardware…", dim)
        },
    ]));
    if !app.ollama_available {
        out.push(Line::from(Span::styled(
            "  ollama: not installed — no problem, /download installs it for you automatically",
            Style::default().fg(AMBER),
        )));
    }
    out.push(Line::default());
    out.push(Line::from(Span::styled(
        "  ── run locally, free (via Ollama) ──",
        Style::default().bold(),
    )));
    for m in LOCAL_MODELS {
        let fits = m.min_ram_gb <= budget;
        let installed = app
            .ollama_tags
            .iter()
            .any(|t| t == m.tag || t.trim_end_matches(":latest") == m.tag);
        let mark = if installed {
            Span::styled("● installed", Style::default().fg(SUCCESS))
        } else if fits && app.hardware.tight_now(m.min_ram_gb) {
            Span::styled("◐ fits*    ", Style::default().fg(AMBER))
        } else if fits {
            Span::styled("✓ fits     ", Style::default().fg(SUCCESS))
        } else {
            Span::styled("✗ too big  ", Style::default().fg(ERROR))
        };
        let tools = if m.tool_calling { "" } else { " (no tools)" };
        out.push(Line::from(vec![
            Span::raw("  "),
            mark,
            Span::raw(" "),
            Span::styled(format!("{:<20}", m.tag), Style::default().fg(ACCENT)),
            Span::styled(
                format!("{:<11}{:>5.1} GB dl • {:>3.0} GB RAM{tools}  {}", m.family, m.download_gb, m.min_ram_gb, m.note),
                dim,
            ),
        ]));
    }
    out.push(Line::default());
    out.push(Line::from(Span::styled(
        "  ── bring your own OpenRouter key ──",
        Style::default().bold(),
    )));
    let mut cloud_line = String::new();
    let mut last_family = "";
    for c in CLOUD_MODELS.iter() {
        if c.family != last_family {
            if !cloud_line.is_empty() {
                cloud_line.push_str("  ");
            }
            cloud_line.push_str(&format!("{}: ", c.family));
            last_family = c.family;
        } else {
            cloud_line.push_str(", ");
        }
        cloud_line.push_str(c.alias);
    }
    for seg in wrap(&cloud_line, 76) {
        out.push(Line::from(vec![Span::raw("  "), Span::styled(seg, dim)]));
    }
    let rc = crate::hardware::recommended_cloud();
    let mut choice = vec![
        Span::styled("  ★ Lucid's choice: ", Style::default().fg(AMBER)),
        Span::styled(rc.alias.to_string(), Style::default().fg(ACCENT)),
    ];
    if rc.free {
        // Free models are the default on Lucid Cloud (just /login) and also
        // free on OpenRouter's preview tier (/apikey then /model <alias>).
        choice.push(Span::styled(" — FREE while in preview", Style::default().fg(SUCCESS)));
        choice.push(Span::styled("  (/login for Lucid Cloud, or /apikey then /model ", dim));
    } else {
        choice.push(Span::styled("  (/apikey then /model ", dim));
    }
    choice.push(Span::styled(rc.alias.to_string(), dim));
    choice.push(Span::styled(")", dim));
    out.push(Line::from(choice));
    if LOCAL_MODELS.iter().any(|m| m.min_ram_gb <= budget && app.hardware.tight_now(m.min_ram_gb)) {
        out.push(Line::from(Span::styled(
            "  ◐ fits* = fits total RAM but free memory is low right now — close apps before running",
            dim,
        )));
    }
    out.push(Line::default());
    out.push(Line::from(Span::styled("  next steps:", Style::default().bold())));
    // Research-backed pick: best tool-calling model that fits this machine
    // (gpt-oss:20b for 16 GB, qwen3-coder:30b for 32 GB+, qwen3:4b for small).
    let rec = crate::hardware::recommended_local(&app.hardware);
    let rec_download = rec
        .map(|m| (format!("/download {}", m.tag), format!("recommended for this machine ({:.1} GB) — {}", m.download_gb, m.note)))
        .unwrap_or_else(|| ("/download qwen3:1.7b".to_string(), "smallest usable agent model".to_string()));
    let rec_model = rec
        .map(|m| format!("/model {}", m.tag))
        .unwrap_or_else(|| "/model qwen3:1.7b".to_string());
    let steps: Vec<(String, String)> = vec![
        (rec_download.0, rec_download.1),
        (rec_model, "then switch to it (Ollama auto-installs if missing)".into()),
        ("/delete <tag>".into(), "remove a downloaded model to free disk".into()),
        ("/skills search <topic>".into(), "find agent skills on skills.sh, install in-session".into()),
        ("/apikey <key>".into(), "bring your OpenRouter key (Kimi, Claude, GPT…)".into()),
    ];
    for (cmd, desc) in steps {
        out.push(Line::from(vec![
            Span::raw("  "),
            Span::styled(format!("{cmd:<24}"), Style::default().fg(ACCENT)),
            Span::styled(desc, dim),
        ]));
    }
}

/// First-run onboarding wizard: a centered menu offering Codex / OpenRouter /
/// local model / explore. Rendered full-screen after the intro; keys handled
/// in `handle_terminal_event`.
fn draw_onboarding(f: &mut Frame, app: &App) {
    let area = f.area();
    let Some(ob) = &app.onboarding else { return };
    let dim = Style::default().add_modifier(Modifier::DIM);
    let mut lines: Vec<Line> = Vec::new();

    lines.push(Line::from(vec![
        Span::styled(">_ ", Style::default().fg(ACCENT).bold()),
        Span::styled("welcome to lucid-train", Style::default().bold()),
    ]));
    lines.push(Line::default());
    lines.push(Line::from(Span::styled(
        "the agentic coding harness — one binary, any model.",
        dim,
    )));
    lines.push(Line::from(vec![
        Span::styled("★ new: ", Style::default().fg(AMBER)),
        Span::styled("Tencent Hy3", Style::default().fg(ACCENT)),
        Span::styled(" — a capable 256K coding model, ", dim),
        Span::styled("FREE while in preview", Style::default().fg(SUCCESS)),
        Span::styled(".", dim),
    ]));
    lines.push(Line::from(vec![
        Span::styled("this machine: ", dim),
        if app.hardware_detected {
            Span::styled(app.hardware.summary(), Style::default().fg(ACCENT))
        } else {
            Span::styled("detecting hardware…", dim)
        },
    ]));
    lines.push(Line::default());
    lines.push(Line::from(Span::styled(
        "how do you want to run models?",
        Style::default().bold(),
    )));
    lines.push(Line::default());

    let rec_tag = crate::hardware::recommended_local(&app.hardware)
        .map(|m| m.tag)
        .unwrap_or("qwen3:4b");
    let codex_ready = crate::codex_auth::load().is_some();

    for (i, (title, blurb)) in super::ONBOARD_OPTIONS.iter().enumerate() {
        let selected = i == ob.selected;
        let marker = if selected { "▸" } else { " " };
        let title_style = if selected {
            Style::default().fg(ACCENT).bold()
        } else {
            Style::default()
        };
        let blurb_text = match i {
            0 if codex_ready => "existing ChatGPT login found — connect instantly (GPT-5.5, Codex)".to_string(),
            2 => format!("downloads {rec_tag} via Ollama — private, offline, free"),
            _ => blurb.to_string(),
        };
        lines.push(Line::from(vec![
            Span::styled(format!("  {marker} "), Style::default().fg(ACCENT)),
            Span::styled(
                format!("{}  ", i + 1),
                if selected { Style::default().fg(ACCENT).bold() } else { dim },
            ),
            Span::styled(format!("{title:<26}"), title_style),
            Span::styled(blurb_text, dim),
        ]));
    }
    lines.push(Line::default());
    lines.push(Line::from(Span::styled(
        "↑/↓ or 1-4 to choose · enter to select · esc to skip — re-open anytime with /onboarding",
        dim,
    )));

    let content_h = lines.len() as u16;
    let top = area.height.saturating_sub(content_h) / 2;
    let inner = Rect {
        x: area.x + 2,
        y: area.y + top,
        width: area.width.saturating_sub(4),
        height: content_h.min(area.height),
    };
    f.render_widget(Paragraph::new(lines), inner);
}

fn draw_approval(f: &mut Frame, app: &App, area: Rect) {
    use super::ApprovalKind;
    let Some(pa) = &app.approval else { return };
    let risk_color = match pa.risk_label.as_str() {
        "dangerous" => ERROR,
        "caution" => AMBER,
        "plan ready" => ACCENT,
        "update" => ACCENT,
        _ => SUCCESS,
    };
    let (question, yes_label, no_label) = match pa.kind {
        ApprovalKind::Tool => ("Approve command? ", " run once   ", " deny"),
        ApprovalKind::FullAuto => ("Confirm policy change? ", " yes, full-auto   ", " cancel"),
        ApprovalKind::ImplementPlan => ("Plan complete. ", " implement it (agent mode)   ", " keep planning"),
        ApprovalKind::Update { .. } => ("New version available. ", " update now   ", " skip this version"),
        ApprovalKind::LoginOrKey { .. } => (
            "Cloud model needs sign-in. ",
            " log in (Google) — no key, billed from your plan   ",
            " paste an OpenRouter key",
        ),
    };
    let mut lines = vec![
        Line::from(vec![
            Span::styled(question, Style::default().bold()),
            Span::styled(
                format!("[{}]", pa.risk_label),
                Style::default().fg(risk_color).bold(),
            ),
            Span::styled(
                if pa.reason.is_empty() { String::new() } else { format!(" {}", pa.reason) },
                Style::default().add_modifier(Modifier::DIM),
            ),
        ]),
        Line::from(Span::styled(
            first_line(&pa.command, area.width.saturating_sub(6) as usize),
            Style::default().fg(AMBER),
        )),
        {
            let mut spans = vec![
                Span::styled("[y]", Style::default().fg(SUCCESS).bold()),
                Span::raw(yes_label),
            ];
            if pa.kind == ApprovalKind::Tool {
                spans.push(Span::styled("[a]", Style::default().fg(ACCENT).bold()));
                spans.push(Span::raw(" approve for session   "));
            }
            spans.push(Span::styled("[n]", Style::default().fg(ERROR).bold()));
            spans.push(Span::raw(no_label));
            Line::from(spans)
        },
    ];
    if pa.command.lines().count() > 1 {
        lines.insert(2, Line::from(Span::styled("(multi-line command — first line shown)", Style::default().add_modifier(Modifier::DIM))));
    }
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(risk_color))
        .title(" approval ");
    f.render_widget(Paragraph::new(lines).block(block), area);
}

fn draw_input(f: &mut Frame, app: &App, area: Rect) {
    let inner_w = area.width.saturating_sub(4) as usize;
    let cursor_col = app.input[..app.cursor].chars().count();

    // ---- inline input prompt (e.g. API key) ----
    if let Some(pi) = &app.pending_input {
        let block = Block::default()
            .borders(Borders::ALL)
            .border_style(Style::default().fg(WARNING))
            .title(format!(" {} ", pi.label));
        // Mask secret input as bullets.
        let shown: String = if pi.masked {
            "•".repeat(app.input.chars().count())
        } else {
            app.input.clone()
        };
        let start = cursor_col.saturating_sub(inner_w.saturating_sub(1));
        let visible: String = shown.chars().skip(start).take(inner_w).collect();
        let line = if app.input.is_empty() {
            Line::from(vec![
                Span::styled("› ", Style::default().fg(WARNING).bold()),
                Span::styled(pi.hint.clone(), Style::default().add_modifier(Modifier::DIM)),
            ])
        } else {
            Line::from(vec![
                Span::styled("› ", Style::default().fg(WARNING).bold()),
                Span::raw(visible),
            ])
        };
        f.render_widget(Paragraph::new(line).block(block), area);
        let cx = area.x + 3 + (cursor_col - start) as u16;
        f.set_cursor_position((cx.min(area.x + area.width.saturating_sub(2)), area.y + 1));
        return;
    }

    let border_style = if app.running {
        // While the train runs, the composer rests in dream-indigo.
        Style::default().fg(SECONDARY).add_modifier(Modifier::DIM)
    } else {
        Style::default().fg(ACCENT)
    };
    let block = Block::default().borders(Borders::ALL).border_style(border_style);

    // Horizontal scroll so the cursor stays visible.
    let start = cursor_col.saturating_sub(inner_w.saturating_sub(1));
    let visible: String = app.input.chars().skip(start).take(inner_w).collect();

    let para = Paragraph::new(Line::from(vec![
        Span::styled("› ", Style::default().fg(ACCENT).bold()),
        Span::raw(visible),
    ]))
    .block(block);
    f.render_widget(para, area);
    let cx = area.x + 3 + (cursor_col - start) as u16;
    let cy = area.y + 1;
    f.set_cursor_position((cx.min(area.x + area.width.saturating_sub(2)), cy));
}

fn draw_status(f: &mut Frame, app: &App, area: Rect) {
    // While a "Sign in with ChatGPT" browser flow is running, show an animated
    // waiting line so the UI never looks frozen.
    if app.login_in_flight {
        let sp = SPINNER[app.spinner_idx % SPINNER.len()];
        let line = Line::from(vec![
            Span::styled(format!(" {sp} "), Style::default().fg(ACCENT).bold()),
            Span::styled(
                "signing in to ChatGPT — finish in your browser, then return here (Esc×2 to cancel)",
                Style::default().fg(ACCENT),
            ),
        ]);
        f.render_widget(Paragraph::new(line), area);
        return;
    }
    let pct = if app.cfg.max_context_tokens > 0 {
        (app.tokens_used as u64 * 100 / app.cfg.max_context_tokens as u64).min(100)
    } else {
        0
    };
    let dim = Style::default().add_modifier(Modifier::DIM);
    let mode_style = match app.cfg.mode {
        crate::config::Mode::Agent => dim,
        crate::config::Mode::Plan => Style::default().fg(WARNING),
        crate::config::Mode::Research => Style::default().fg(SECONDARY),
    };
    let line = Line::from(vec![
        Span::styled(format!(" {} ", app.cfg.model_alias), Style::default().fg(ACCENT)),
        Span::styled(format!("• {} ", app.cfg.provider.label()), dim),
        Span::styled(format!("• {} ", app.cfg.mode.label()), mode_style),
        Span::styled(format!("• approvals:{} ", app.cfg.approval.label()), dim),
        Span::styled(
            format!("• ↑{} ↓{} ", fmt_tokens(app.session_input), fmt_tokens(app.session_output)),
            dim,
        ),
        Span::styled(format!("• ctx {pct}% "), if pct >= 75 { Style::default().fg(AMBER) } else { dim }),
        Span::styled("• scroll: mouse wheel • copy: select text or /copy • ↑↓ history • Esc×2 quit", dim),
    ]);
    f.render_widget(Paragraph::new(line), area);
}

/// 1234 -> "1.2k", 1234567 -> "1.2M".
pub fn fmt_tokens(n: u64) -> String {
    if n >= 1_000_000 {
        format!("{:.1}M", n as f64 / 1_000_000.0)
    } else if n >= 1_000 {
        format!("{:.1}k", n as f64 / 1_000.0)
    } else {
        n.to_string()
    }
}

// ---------- file-operation detection (Codex-style "Edited x" labels) ----------

/// Detect commands that write/edit/read files so the exec cell can show a
/// human label ("Editing src/main.rs") instead of raw shell.
pub fn detect_file_op(command: &str) -> Option<(&'static str, String)> {
    use regex::Regex;
    use std::sync::OnceLock;
    static WRITE_RE: OnceLock<Regex> = OnceLock::new();
    static SED_RE: OnceLock<Regex> = OnceLock::new();
    static TEE_RE: OnceLock<Regex> = OnceLock::new();
    static MKDIR_RE: OnceLock<Regex> = OnceLock::new();
    let write_re = WRITE_RE.get_or_init(|| {
        Regex::new(r"(?:^|\s)(?:cat|echo|printf)\b[^|;&]*?>{1,2}\s*([\w@./~-]+)").unwrap()
    });
    let sed_re = SED_RE.get_or_init(|| {
        // Handles both GNU (`sed -i 's///' f`) and macOS (`sed -i '' 's///' f`).
        Regex::new(r#"\bsed\s+(?:-[a-zA-Z]+\s+)*-i\S*\s+(?:''\s+|""\s+)?(?:'[^']*'|"[^"]*"|\S+)\s+([\w@./~-]+)"#).unwrap()
    });
    let tee_re = TEE_RE.get_or_init(|| Regex::new(r"\btee\s+(?:-a\s+)?([\w@./~-]+)").unwrap());
    let mkdir_re = MKDIR_RE.get_or_init(|| Regex::new(r"^\s*mkdir\s+(?:-p\s+)?([\w@./~-]+)").unwrap());

    // Heredoc writes: cat > file << 'EOF'
    if let Some(c) = write_re.captures(command) {
        let verb = if command.contains("<<") || command.lines().count() > 1 { "Writing" } else { "Writing" };
        return Some((verb, c[1].to_string()));
    }
    if let Some(c) = sed_re.captures(command) {
        return Some(("Editing", c[1].to_string()));
    }
    if let Some(c) = tee_re.captures(command) {
        return Some(("Writing", c[1].to_string()));
    }
    if let Some(c) = mkdir_re.captures(command) {
        return Some(("Creating", c[1].to_string()));
    }
    None
}

fn past_tense(verb: &str) -> &'static str {
    match verb {
        "Writing" => "Wrote",
        "Editing" => "Edited",
        "Creating" => "Created",
        _ => "Ran",
    }
}

// ---------- lightweight markdown ----------

fn render_markdown(text: &str, width: usize, out: &mut Vec<Line>) {
    // For very long assistant cells, only render the tail that can actually
    // fit on screen. This keeps memory/CPU bounded during long streaming
    // outputs without losing the latest content.
    let keep_lines = MAX_CELL_RENDER_LINES;
    let source_lines: Vec<&str> = text.lines().collect();
    let skip = source_lines.len().saturating_sub(keep_lines);
    let source_lines = &source_lines[skip..];
    let has_more = skip > 0;

    let mut in_code = false;
    for raw_line in source_lines {
        let trimmed = raw_line.trim_start();

        // Fenced code blocks, with language label on the opening fence.
        if trimmed.starts_with("```") {
            if !in_code {
                let lang = trimmed.trim_start_matches('`').trim();
                let label = if lang.is_empty() { String::new() } else { format!(" {lang} ") };
                out.push(Line::from(vec![
                    Span::styled("  ╭──", Style::default().add_modifier(Modifier::DIM)),
                    Span::styled(label, Style::default().fg(ACCENT).add_modifier(Modifier::DIM)),
                    Span::styled("─".repeat(8), Style::default().add_modifier(Modifier::DIM)),
                ]));
            } else {
                out.push(Line::from(Span::styled(
                    format!("  ╰{}", "─".repeat(12)),
                    Style::default().add_modifier(Modifier::DIM),
                )));
            }
            in_code = !in_code;
            continue;
        }
        if in_code {
            let body = truncate_display(raw_line, width.saturating_sub(4));
            out.push(Line::from(vec![
                Span::styled("  │ ", Style::default().add_modifier(Modifier::DIM)),
                Span::styled(body, Style::default().fg(AMBER)),
            ]));
            continue;
        }
        if raw_line.trim().is_empty() {
            if out.len() > 1 || !has_more {
                out.push(Line::default());
            }
            continue;
        }
        // Horizontal rule.
        if matches!(raw_line.trim(), "---" | "***" | "___") {
            out.push(Line::from(Span::styled(
                "─".repeat(width.min(40)),
                Style::default().add_modifier(Modifier::DIM),
            )));
            continue;
        }
        // Blockquote.
        if let Some(q) = trimmed.strip_prefix("> ").or_else(|| trimmed.strip_prefix(">")) {
            for seg in wrap(q, width.saturating_sub(4)) {
                out.push(Line::from(vec![
                    Span::styled("  ▌ ", Style::default().fg(SECONDARY)),
                    Span::styled(seg, Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM)),
                ]));
            }
            continue;
        }

        // Numbered list: "1. text" → hanging indent.
        let numbered: Option<(String, String)> = trimmed
            .split_once(". ")
            .filter(|(n, _)| !n.is_empty() && n.len() <= 3 && n.chars().all(|c| c.is_ascii_digit()))
            .map(|(n, rest)| (format!("  {n}. "), rest.to_string()));

        let (indent, styled_base, content) = if let Some(h) = raw_line.strip_prefix("### ") {
            ("".to_string(), Style::default().bold(), h.to_string())
        } else if let Some(h) = raw_line.strip_prefix("## ") {
            ("".to_string(), Style::default().fg(ACCENT).bold(), h.to_string())
        } else if let Some(h) = raw_line.strip_prefix("# ") {
            ("".to_string(), Style::default().fg(ACCENT).bold().underlined(), h.to_string())
        } else if let Some(b) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
            // Preserve nesting depth for sub-bullets.
            let depth = (raw_line.len() - trimmed.len()) / 2;
            (format!("{}• ", "  ".repeat(depth + 1)), Style::default(), b.to_string())
        } else if let Some((num_indent, rest)) = numbered {
            (num_indent, Style::default(), rest)
        } else {
            ("".to_string(), Style::default(), raw_line.to_string())
        };
        for (i, seg) in wrap(&content, width.saturating_sub(indent.len())).into_iter().enumerate() {
            let mut spans: Vec<Span> = Vec::new();
            if i == 0 && !indent.is_empty() {
                spans.push(Span::raw(indent.clone()));
            } else if !indent.is_empty() {
                spans.push(Span::raw(" ".repeat(indent.len())));
            }
            spans.extend(style_inline(&seg, styled_base));
            out.push(Line::from(spans));
        }
    }
    if has_more {
        out.push(Line::from(Span::styled(
            format!("  … {skip} earlier lines truncated"),
            Style::default().add_modifier(Modifier::DIM),
        )));
    }
}

/// Inline `code` and **bold** styling, with URLs linkified.
fn style_inline(s: &str, base: Style) -> Vec<Span<'static>> {
    let mut spans = Vec::new();
    let mut rest = s;
    loop {
        let tick = rest.find('`');
        let bold = rest.find("**");
        match (tick, bold) {
            (None, None) => {
                linkify_into(rest, base, &mut spans);
                break;
            }
            (Some(t), b) if b.map_or(true, |b| t < b) => {
                if let Some(end) = rest[t + 1..].find('`') {
                    linkify_into(&rest[..t], base, &mut spans);
                    spans.push(Span::styled(
                        rest[t + 1..t + 1 + end].to_string(),
                        base.fg(AMBER),
                    ));
                    rest = &rest[t + 2 + end..];
                } else {
                    linkify_into(rest, base, &mut spans);
                    break;
                }
            }
            (_, Some(b)) => {
                if let Some(end) = rest[b + 2..].find("**") {
                    linkify_into(&rest[..b], base, &mut spans);
                    spans.push(Span::styled(
                        rest[b + 2..b + 2 + end].to_string(),
                        base.add_modifier(Modifier::BOLD),
                    ));
                    rest = &rest[b + 4 + end..];
                } else {
                    linkify_into(rest, base, &mut spans);
                    break;
                }
            }
            _ => unreachable!(),
        }
    }
    spans
}

/// Split plain text on http(s) URLs and style them as underlined links.
/// (With mouse capture off, terminals make these Cmd/Ctrl+clickable.)
fn linkify_into(text: &str, base: Style, spans: &mut Vec<Span<'static>>) {
    let link_style = Style::default().fg(ACCENT).add_modifier(Modifier::UNDERLINED);
    let mut rest = text;
    while let Some(pos) = rest.find("http://").or_else(|| rest.find("https://")) {
        // Some find() returns the earliest of either scheme — recompute min.
        let pos = rest
            .find("http://")
            .into_iter()
            .chain(rest.find("https://"))
            .min()
            .unwrap_or(pos);
        if pos > 0 {
            spans.push(Span::styled(rest[..pos].to_string(), base));
        }
        let tail = &rest[pos..];
        let end = tail
            .find(|c: char| c.is_whitespace() || c == '<' || c == '>' || c == '"' || c == '\'' || c == ')')
            .unwrap_or(tail.len());
        spans.push(Span::styled(tail[..end].to_string(), link_style));
        rest = &tail[end..];
    }
    if !rest.is_empty() {
        spans.push(Span::styled(rest.to_string(), base));
    }
}

// ---------- helpers ----------

fn first_line(s: &str, width: usize) -> String {
    let l = s.lines().next().unwrap_or("");
    truncate_display(l.trim(), width)
}

fn shorten_path(p: &str, max: usize) -> String {
    if display_len(p) <= max {
        return p.to_string();
    }
    let suffix = truncate_display(p, max.saturating_sub(3));
    format!("…{suffix}")
}

// ---------- tests ----------

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

    #[test]
    fn nearest_256_never_uses_themed_low_colors() {
        // Indices 0..=15 are remapped by the terminal theme; we must never
        // emit them, or the fallback would be theme-dependent again.
        for &(r, g, b) in &[
            (0x1a, 0x8f, 0xe3), // ACCENT
            (0x63, 0x66, 0xf1), // SECONDARY
            (0x16, 0xa3, 0x4a), // SUCCESS
            (0xdc, 0x26, 0x26), // ERROR
            (0xd4, 0xa0, 0x44), // AMBER
            (0x00, 0x00, 0x00), // pure black
            (0xff, 0xff, 0xff), // pure white
            (0x80, 0x80, 0x80), // mid gray
        ] {
            assert!(nearest_256(r, g, b) >= 16, "rgb({r},{g},{b}) mapped into themed range");
        }
    }

    #[test]
    fn nearest_256_picks_cube_corners_and_grays() {
        assert_eq!(nearest_256(0, 0, 0), 16); // cube origin
        assert_eq!(nearest_256(255, 255, 255), 231); // cube max (whiter than gray ramp)
        // A neutral mid gray is closer to the grayscale ramp than the cube.
        let idx = nearest_256(0x77, 0x77, 0x77);
        assert!((232..=255).contains(&idx), "expected grayscale ramp, got {idx}");
    }

    #[test]
    fn map_color_passes_through_non_rgb() {
        assert_eq!(map_color(Color::Reset), Color::Reset);
        assert_eq!(map_color(Color::Indexed(42)), Color::Indexed(42));
        assert!(matches!(map_color(Color::Rgb(0x1a, 0x8f, 0xe3)), Color::Indexed(_)));
    }

    #[test]
    fn sanitize_strips_ansi_and_replacements() {
        let raw = "\x1b[31mhello\x1b[0m \u{FFFD}\u{200B}world";
        let clean = sanitize_display(raw);
        assert_eq!(clean, "hello �world");
    }

    #[test]
    fn sanitize_strips_osc_hyperlinks() {
        // OSC-8 hyperlink (BEL-terminated): ESC ]8;;URL BEL text ESC ]8;; BEL.
        let bel = "\x1b]8;;https://example.com\x07link\x1b]8;;\x07";
        assert_eq!(sanitize_display(bel), "link");
        // ST-terminated form: ESC ]8;;URL ESC \ text ESC ]8;; ESC \.
        let st = "\x1b]8;;https://ex.com\x1b\\text\x1b]8;;\x1b\\";
        assert_eq!(sanitize_display(st), "text");
    }

    #[test]
    fn wrap_respects_cjk_width() {
        // Each CJK char is 2 columns. The wrapper first tries to keep whole
        // words on a line, then breaks individual words at display-width
        // boundaries. With width 5, the first line holds 2 chars (width 4).
        let lines = wrap("中文测试", 5);
        assert_eq!(lines, vec!["中文".to_string(), "测试".to_string()]);
    }

    #[test]
    fn truncate_display_honours_emoji_width() {
        let s = "a🎉b";
        // width 3 → 'a' (1) + emoji (2) = 3; drop 'b'.
        assert_eq!(truncate_display(s, 3), "a🎉");
    }

    #[test]
    fn markdown_truncates_long_tail() {
        let text: String = (0..5000).map(|i| format!("line {i}\n")).collect();
        let mut out = Vec::new();
        render_markdown(&text, 40, &mut out);
        assert!(out.len() <= MAX_CELL_RENDER_LINES + 2);
    }

    #[test]
    fn wrap_counts_combining_marks_as_zero_width() {
        // Decomposed "é" = 'e' + U+0301 combining acute; display width 1, not 2.
        let s = "e\u{0301}e\u{0301}e\u{0301}"; // three glyphs = 3 display columns
        assert_eq!(display_len(s), 3);
        // Fits exactly in width 3 → one line. The old `cw.max(1)` counted each
        // mark as a column (width 6) and wrapped this early.
        assert_eq!(wrap(s, 3), vec![s.to_string()]);
    }

    #[test]
    fn truncate_display_counts_combining_marks_as_zero_width() {
        let s = "e\u{0301}e\u{0301}e\u{0301}"; // 3 display columns
        assert_eq!(truncate_display(s, 3), s.to_string());
    }

    #[test]
    fn render_markdown_keeps_cjk_contiguous() {
        // Regression guard: the assistant render path must not inject spaces
        // between wide (CJK) glyphs — contiguous input stays contiguous.
        let mut out = Vec::new();
        render_markdown("你好世界", 40, &mut out);
        let text: String = out
            .iter()
            .flat_map(|l| l.spans.iter().map(|s| s.content.as_ref()))
            .collect();
        assert!(text.contains("你好世界"), "got: {text:?}");
    }
}
