use std::collections::VecDeque;
use std::io::{self, Write as _};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};

use crossterm::event::{
    self, DisableBracketedPaste, EnableBracketedPaste, EnableMouseCapture, DisableMouseCapture,
    Event, KeyCode, KeyEvent, KeyModifiers, MouseEventKind,
};
use crossterm::terminal::{
    disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
use crossterm::ExecutableCommand;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
use ratatui::Terminal;

use pulldown_cmark::{
    Event as MdEvent, HeadingLevel, Options as MdOptions, Parser as MdParser, Tag, TagEnd,
};

use commands::slash_command_specs;
use runtime::AssistantEvent;
use api;

// ── Colors ────────────────────────────────────────────────────────────────────

const BG: Color = Color::Rgb(13, 17, 33);
const FG: Color = Color::Rgb(220, 220, 220);
const DIM: Color = Color::Rgb(80, 80, 80);
const GREY: Color = Color::Rgb(145, 145, 145);
const GREEN: Color = Color::Rgb(0, 220, 120);
const CYAN: Color = Color::Rgb(0, 200, 255);
const ORANGE: Color = Color::Rgb(255, 140, 50);   // working `*` indicator
const USER_BOX_BG: Color = Color::Reset;
const STATUS_BG: Color = Color::Reset;
const BRANCH_BG: Color = Color::Reset;  // git branch pill background
const POPUP_BG: Color = Color::Rgb(20, 26, 48);
const POPUP_MATCH: Color = Color::Rgb(0, 180, 100);
const POPUP_SEL_BG: Color = Color::Rgb(42, 42, 42);
const CODE_FG: Color = Color::Rgb(100, 210, 255);    // inline code / code blocks
const CODE_BG: Color = Color::Reset;       // code block row background
const CODE_BAR: Color = Color::Rgb(0, 100, 160);     // code block left border bar
const CHAT_BORDER: Color = Color::Rgb(0, 65, 75);    // chat area frame
const INPUT_BORDER: Color = Color::Rgb(0, 175, 160); // input box turquoise frame
const ERROR_FG: Color = Color::Rgb(230, 80, 50);     // error lines in tool output
const POPUP_WINDOW: usize = 16;                       // max items visible at once in popup
const CATEGORY_FG: Color = Color::Rgb(60, 60, 60);   // greyed-out category headers in popup

/// Returns a Style that "breathes" (interpolates brightness) over time.
/// Used to unify the pulse effect across the status bar, tool calls, and tasks.
fn get_pulse_style(elapsed: f32, is_active: bool) -> Style {
    if !is_active {
        return Style::default().fg(GREY);
    }

    let period = 2.0 / 2.5; // Pulse frequency (~0.8s per cycle)
    let t = (elapsed % period) / period;
    let intensity = ((t * std::f32::consts::PI).sin()).powf(2.0);

    // LERP from Grey (80,80,80) to Turquoise (0,200,255)
    let r = (80.0 + (0.0 - 80.0) * intensity) as u8;
    let g = (80.0 + (200.0 - 80.0) * intensity) as u8;
    let b = (80.0 + (255.0 - 80.0) * intensity) as u8;

    Style::default()
        .fg(Color::Rgb(r, g, b))
        .add_modifier(Modifier::BOLD)
}

/// Shimmer effect: creates a moving light gradient across text
/// Returns styled spans with position-based brightness
fn get_shimmer_spans(text: &str, elapsed: f32) -> Vec<Span<'static>> {
    if text.is_empty() {
        return vec![Span::raw("")];
    }

    let text_len = text.len() as f32;
    let period = 3.0; // 3 second cycle for left-to-right sweep
    let phase = (elapsed % period) / period; // 0..1
    let shimmer_pos = phase * (text_len + 4.0); // Travel beyond text for smooth exit

    let mut spans = Vec::new();
    let chars: Vec<char> = text.chars().collect();

    for (idx, &ch) in chars.iter().enumerate() {
        let idx_f = idx as f32;
        let dist_from_shimmer = (idx_f - shimmer_pos).abs();
        let shimmer_width = 6.0; // Width of the shimmer effect

        // Calculate brightness: brightest at center (dist=0), fades to normal at edges
        let brightness = if dist_from_shimmer < shimmer_width {
            let factor = 1.0 - (dist_from_shimmer / shimmer_width);
            factor * factor // Quadratic falloff for smoother look
        } else {
            0.0
        };

        // Base color (turquoise): (0, 200, 255)
        // Shimmer adds brightness (white highlight effect)
        let base_r = 0u8;
        let base_g = 200u8;
        let base_b = 255u8;

        let r = (base_r as f32 + (255.0 - base_r as f32) * brightness * 0.6) as u8;
        let g = (base_g as f32 + (255.0 - base_g as f32) * brightness * 0.4) as u8;
        let b = (base_b as f32 + (255.0 - base_b as f32) * brightness * 0.3) as u8;

        let style = Style::default()
            .fg(Color::Rgb(r, g, b))
            .add_modifier(if brightness > 0.5 { Modifier::BOLD } else { Modifier::empty() });

        spans.push(Span::styled(ch.to_string(), style));
    }

    spans
}

// Tip lines shown below the working indicator — cycle by elapsed seconds
const TIPS: &[&str] = &[
    "Use /compress to free context space mid-session",
    "Use /model to switch providers without losing session history",
    "Use /permissions danger-full-access for unrestricted shell access",
    "PageUp / PageDown to scroll through conversation history",
    "Type /help to see all available slash commands",
    "Press esc to interrupt a running turn at any time",
    "Use /session list to see and switch between saved sessions",
    "Use /init to scaffold an ALBERT.md for project context",
    "Use /memory to view the agent's persistent long-term memory",
    "Use /commit to have AI draft a perfect git commit message",
    "Use /diff to review current changes before committing",
    "Use /tdd to enter a test-driven development loop",
    "Use /bughunter to scan the codebase for potential issues",
    "Use /refactor to improve the structure of the current file",
    "Ctrl+L scrolls the conversation back to the bottom",
    "Ctrl+Space toggles voice recording (STT) for hands-free input",
    "Shift+Enter adds a newline to your message",
    "Tab completes slash commands and opens sub-menus",
    "Use /aside to take temporary notes during a deep session",
    "Use /export to save the current conversation to a file",
    "Use /checkpoint to save a snapshot of the current workspace",
];

// Permission modes (in display/cycle order)
const PERM_MODES: &[(&str, &str)] = &[
    ("read-only",           "no writes · no shell"),
    ("workspace-write",     "files only · no shell"),
    ("danger-full-access",  "unrestricted · full shell"),
];

// Known models for the in-popup model picker — (id, provider, description)
const MODEL_ENTRIES: &[(&str, &str, &str)] = &[
    // Google
    ("gemini-2.5-pro",                              "Google",       "Most capable Gemini"),
    ("gemini-2.5-flash",                            "Google",       "Fast & capable — recommended"),
    ("gemini-2.5-flash-8b",                          "Google",       "Lightest Gemini"),
    // Anthropic
    ("claude-opus-4-7",                             "Anthropic",    "Most capable Claude"),
    ("claude-sonnet-4-6",                           "Anthropic",    "Best balance"),
    ("claude-haiku-4-5-20251001",                   "Anthropic",    "Fastest Claude"),
    // OpenAI
    ("gpt-4o",                                      "OpenAI",       "GPT-4o flagship"),
    ("gpt-4o-mini",                                 "OpenAI",       "Efficient GPT-4o"),
    ("o4-mini",                                      "OpenAI",       "o4-mini reasoning — efficient"),
    ("o3",                                          "OpenAI",       "Full o3 reasoning"),
    ("o3-mini",                                     "OpenAI",       "o3 reasoning — efficient"),
    // xAI
    ("grok-3",                                      "xAI",          "Grok 3 flagship"),
    ("grok-3-mini",                                 "xAI",          "Efficient Grok"),
    // Groq LPU
    ("llama-3.3-70b-versatile",                     "Groq",         "Llama 3.3 70B — ultra-fast LPU"),
    ("llama-3.1-8b-instant",                        "Groq",         "Llama 3.1 8B — fastest/cheapest"),
    ("gemma2-9b-it",                                "Groq",         "Gemma2 9B on Groq"),
    // Mistral
    ("mistral-large-latest",                        "Mistral",      "Mistral Large 2"),
    ("mistral-small-latest",                        "Mistral",      "Mistral Small — fast"),
    ("codestral-latest",                            "Mistral",      "Code specialist"),
    ("pixtral-large-latest",                        "Mistral",      "Pixtral multimodal"),
    // DeepSeek
    ("deepseek-chat",                               "DeepSeek",     "DeepSeek V3 flagship"),
    ("deepseek-reasoner",                           "DeepSeek",     "DeepSeek R1 chain-of-thought"),
    // OpenRouter
    ("openai/gpt-4o",                               "OpenRouter",   "GPT-4o via OpenRouter"),
    ("anthropic/claude-sonnet-4-6",                 "OpenRouter",   "Claude Sonnet 4.6"),
    ("google/gemini-2.5-flash",                     "OpenRouter",   "Gemini Flash"),
    ("x-ai/grok-3-mini",                            "OpenRouter",   "Grok 3 Mini"),
    // Perplexity
    ("sonar-pro",                                   "Perplexity",   "Search-grounded Pro"),
    ("sonar",                                       "Perplexity",   "Search-grounded Fast"),
    // Cohere
    ("command-r-plus",                              "Cohere",       "Command R+ RAG flagship"),
    ("command-r",                                   "Cohere",       "Command R — efficient"),
    // Cerebras
    ("llama3.3-70b",                                "Cerebras",     "Llama 3.3 70B on WSE"),
    // Qwen
    ("qwen-max",                                    "Qwen",         "Qwen Max flagship"),
    ("qwq-32b",                                     "Qwen",         "QwQ 32B chain-of-thought"),
    // NVIDIA NIM
    ("nvidia/llama-3.1-nemotron-70b-instruct",      "NVIDIA NIM",   "Nemotron 70B"),
    // Together AI
    ("meta-llama/Meta-Llama-3.1-70B-Instruct-Turbo","Together",     "Llama 3.1 70B Turbo"),
    // Local
    ("llama3.2",                                    "Ollama",       "Llama 3.2 local"),
    ("phi4",                                        "Ollama",       "Phi-4 local"),
    ("qwen2.5-coder:14b",                           "Ollama",       "Qwen2.5 Coder local"),
    ("local-model",                                 "LM Studio",    "Active LM Studio model"),
];

// ── Data model ────────────────────────────────────────────────────────────────

#[derive(Clone, Debug, PartialEq, Eq)]
pub enum TaskStatus {
    #[allow(dead_code)]
    Pending,
    Running,
    Done,
    Failed,
}

#[derive(Clone, Debug)]
pub struct Task {
    pub id: String,
    pub label: String,
    pub status: TaskStatus,
}

/// A single line in an xray diff view.
#[derive(Clone, Debug)]
pub enum XRayLine {
    Added   { n: usize, text: String },
    Removed { n: usize, text: String },
    Context { n: usize, text: String },
    Elided  { count: usize },
}

/// Diff summary shown below an Edit/Write tool call.
#[derive(Clone, Debug)]
pub struct XRayDiff {
    pub file:    String,
    pub added:   usize,
    pub removed: usize,
    pub lines:   Vec<XRayLine>,
}

#[derive(Clone, Debug)]
pub enum ExecBlock {
    /// User message:  > text  on slightly dark background
    UserMessage(String),
    /// Tool call — green dot while active, grey when done
    ToolUse { name: String, args: String, active: bool, xray: Option<XRayDiff> },
    /// Real-time task tree [ ] [●] [✔]
    Plan { tasks: Vec<Task>, frozen: bool },
    /// L-shaped output under a ToolUse
    ToolOutput { lines: Vec<String>, total: usize, active: bool },
    /// Streaming agent text
    AgentText(String, bool), // (text, is_interrupted)
    /// Reasoning/thinking text from a thinking model — grey+italic, below the input bar
    Thinking(String),
    /// System / info note
    SystemMsg(String),
    /// Post-turn elapsed time: "Worked for Xm Ys"
    WorkedFor(u64),
    // /// Pre-formatted verbatim text — bypasses markdown, renders each line as-is.
    // RawText(String),
}

#[derive(Clone, Debug)]
#[allow(dead_code)]
pub struct ImageAttachment {
    pub path: std::path::PathBuf,
    pub base64: String,
    pub mime: String,
    pub thumb: String,
}

#[derive(Debug)]
pub struct ToolApprovalState {
    pub _id: String,
    pub name: String,
    pub input: serde_json::Value,
    pub resp_tx: std::sync::mpsc::SyncSender<runtime::PermissionPromptDecision>,
}

/// Two-phase auth flow: collect key, then pick a model from the live registry.
#[derive(Clone, Debug)]
pub enum AuthFlowPhase {
    Key  { provider: String },
    Model { provider: String, models: Vec<String> },
}

#[derive(Clone, Debug)]
pub struct TuiState {
    pub exec_log: VecDeque<ExecBlock>,
    pub input: String,
    pub cursor: usize,
    pub tokens_in: u32,
    pub tokens_out: u32,
    pub model: String,
    pub cwd: String,
    pub permission_mode: String,
    pub session_start: Instant,
    /// Set when a turn starts, cleared when it ends — drives the working timer.
    pub turn_start: Option<Instant>,
    pub working: bool,
    /// Rows scrolled up from the bottom (0 = follow latest)
    pub scroll: u16,
    /// Selected index in the active popup
    pub popup_selected: usize,
    /// True while voice recording is active (Ctrl+Space toggle)
    pub is_recording: bool,
    /// True while awaiting transcription result (between stop-record and VoiceText/Error)
    pub voice_transcribing: bool,
    /// Animated spine frame counter (0..3), incremented on Tick when working=true
    pub spine_frame: u8,
    /// Set while in auth flow — phase 1 collects the key, phase 2 picks the model.
    pub auth_flow: Option<AuthFlowPhase>,
    /// Set when the current input arrived via a large paste (>= 3 lines).
    /// Drives the compact "pasted text · N lines" badge in render_input.
    pub paste_line_count: Option<usize>,
    /// Show the full help popup overlay (opened by /help, closed by Esc).
    pub help_open: bool,
    /// Scroll offset inside the help popup.
    pub help_scroll: u16,
    /// Buffer for the typewriter effect (flowing text deltas).
    pub typewriter_buffer: String,
    /// Track the index of the last active assistant text block for correct turn anchoring.
    pub current_assistant_block_index: Option<usize>,
    /// Ordered list of previously submitted messages (max 200).
    pub input_history: Vec<String>,
    /// Index into input_history while browsing (None = not browsing).
    pub history_idx: Option<usize>,
    /// Stashed live input while browsing history — restored on Down past end.
    pub input_saved: String,

    // ── HITL ─────────────────────────────────────────────────────────────────
    /// HITL: Set when waiting for the user to approve a plan.
    pub _awaiting_plan_approval: bool,
    /// HITL: Set when waiting for tool approval.
    pub awaiting_tool_approval: Option<Arc<Mutex<Option<ToolApprovalState>>>>,
    /// HITL: Currently highlighted option (0=Approve, 1=Session, 2=Changes, 3=Deny).
    pub hitl_selected: usize,
    /// Tools approved for the rest of this session (skip HITL on re-use).
    pub session_approved_tools: std::collections::HashSet<String>,

    // ── Session Metrics ──────────────────────────────────────────────────────
    pub tool_calls: usize,
    pub tool_success: usize,
    pub tool_failure: usize,
    pub agent_active_ms: u64,
    pub api_time_ms: u64,
    pub tool_time_ms: u64,
    pub session_id: String,

    /// Images queued for the next send (attached via Ctrl+I).
    pub pending_images: Vec<ImageAttachment>,
    /// True while the image-path overlay is open (Ctrl+I pressed, awaiting path entry).
    pub image_path_overlay: bool,

    /// Set when Ctrl+C is pressed once.
    pub quit_confirm: bool,
    /// Whether the user has explicitly trusted this directory for this session.
    pub trusted: bool,
    /// Whether the agent is currently blocked waiting for a permission prompt response.
    pub is_prompting: Arc<AtomicBool>,
    /// Track which exec_log blocks are collapsed (by index). Toggle with Ctrl+O.
    pub collapsed_blocks: std::collections::HashSet<usize>,
    /// Live model list fetched from the active provider's API — used by /model popup.
    pub model_list_cache: Option<Vec<String>>,
    /// Thinking text streaming buffer — drained typewriter-style into the last Thinking block.
    pub thinking_typewriter_buffer: String,
    /// Index of the currently active Thinking block in exec_log (for typewriter updates).
    pub current_thinking_block_index: Option<usize>,
    /// All distinct model IDs used this session — shown in exit card.
    pub models_used: Vec<String>,
}

impl Default for TuiState {
    fn default() -> Self {
        Self {
            exec_log: VecDeque::new(),
            input: String::new(),
            cursor: 0,
            tokens_in: 0,
            tokens_out: 0,
            model: String::new(),
            cwd: String::new(),
            permission_mode: String::new(),
            session_start: Instant::now(),
            turn_start: None,
            working: false,
            scroll: 0,
            popup_selected: 0,
            is_recording: false,
            voice_transcribing: false,
            spine_frame: 0,
            auth_flow: None,
            paste_line_count: None,
            help_open: false,
            help_scroll: 0,
            typewriter_buffer: String::new(),
            current_assistant_block_index: None,
            input_history: Vec::new(),
            history_idx: None,
            input_saved: String::new(),
            _awaiting_plan_approval: false,
            awaiting_tool_approval: None,
            hitl_selected: 0,
            session_approved_tools: std::collections::HashSet::new(),
            tool_calls: 0,
            tool_success: 0,
            tool_failure: 0,
            agent_active_ms: 0,
            api_time_ms: 0,
            tool_time_ms: 0,
            session_id: String::new(),
            pending_images: Vec::new(),
            image_path_overlay: false,
            quit_confirm: false,
            trusted: false,
            is_prompting: Arc::new(AtomicBool::new(false)),
            collapsed_blocks: std::collections::HashSet::new(),
            model_list_cache: None,
            thinking_typewriter_buffer: String::new(),
            current_thinking_block_index: None,
            models_used: Vec::new(),
        }
    }
}

impl TuiState {
    pub fn new(model: String, cwd: String, permission_mode: String, session_id: String) -> Self {
        Self { model, cwd, permission_mode, session_id, ..Default::default() }
    }

    /// Record a model as used this session (deduplicates).
    pub fn record_model(&mut self) {
        let m = self.model.clone();
        if !m.is_empty() && !self.models_used.contains(&m) {
            self.models_used.push(m);
        }
    }

    pub fn push_exec(&mut self, block: ExecBlock) {
        if matches!(&block, ExecBlock::UserMessage(_)) {
            self.seal_last_assistant_block();
            self.current_assistant_block_index = None;
            self.typewriter_buffer.clear();
            self.thinking_typewriter_buffer.clear();
            self.current_thinking_block_index = None;
        }
        // Auto-follow: pin to bottom whenever content arrives during a working turn.
        if self.working {
            self.scroll = 0;
        }

        if matches!(&block, ExecBlock::ToolUse { .. }) {
            self.deactivate_all_tools();
        }

        // Filter out meta-tools from "Ran [tool]" display
        if let ExecBlock::ToolUse { ref name, .. } = block {
            if name == "SendUserMessage" || name == "Brief" {
                return;
            }
        }

        // Suppress consecutive identical SystemMsg entries (e.g. repeated voice errors).
        if let ExecBlock::SystemMsg(ref msg) = block {
            if let Some(ExecBlock::SystemMsg(last)) = self.exec_log.back() {
                if last == msg {
                    return;
                }
            }
        }

        self.exec_log.push_back(block);

        // Track the index of AssistantResponse blocks for turn anchoring
        if matches!(self.exec_log.back(), Some(ExecBlock::AgentText(..))) {
            self.current_assistant_block_index = Some(self.exec_log.len() - 1);
        } else {
            // Any other block (ToolUse, Plan, etc.) breaks the continuity of the text block.
            self.current_assistant_block_index = None;
        }

        // Track the index of the latest Thinking block for typewriter drain.
        if matches!(self.exec_log.back(), Some(ExecBlock::Thinking(..))) {
            self.current_thinking_block_index = Some(self.exec_log.len() - 1);
        }

        // Keep the log bounded so rendering stays fast — older blocks are trimmed.
        while self.exec_log.len() > 120 {
            self.exec_log.pop_front();
            // Adjust current_assistant_block_index for the removed front element.
            if let Some(ref mut idx) = self.current_assistant_block_index {
                if *idx == 0 {
                    self.current_assistant_block_index = None;
                } else {
                    *idx -= 1;
                }
            }
            // Adjust current_thinking_block_index for the removed front element.
            if let Some(ref mut idx) = self.current_thinking_block_index {
                if *idx == 0 {
                    self.current_thinking_block_index = None;
                } else {
                    *idx -= 1;
                }
            }
            // Adjust collapsed_blocks — all indices shift down by 1 after pop_front.
            // Entries at 0 referred to the block that was just removed; drop them.
            self.collapsed_blocks = self.collapsed_blocks
                .drain()
                .filter_map(|i| if i == 0 { None } else { Some(i - 1) })
                .collect();
        }
    }

    /// Seal the current assistant turn: freeze plans and mark text as interrupted if needed.
    pub fn seal_last_assistant_block(&mut self) {
        // Freeze any active plans
        for block in self.exec_log.iter_mut() {
            if let ExecBlock::Plan { frozen, .. } = block {
                *frozen = true;
            }
        }
        
        // If the turn ended via interruption (before MessageStop), mark the last text block.
        if let Some(idx) = self.current_assistant_block_index {
            if let Some(ExecBlock::AgentText(_, interrupted)) = self.exec_log.get_mut(idx) {
                *interrupted = true;
            }
        }
    }


    /// Mark all active ToolUse as completed (grey dot).
    pub fn deactivate_all_tools(&mut self) {
        for block in self.exec_log.iter_mut() {
            match block {
                ExecBlock::ToolUse { active, .. } => *active = false,
                ExecBlock::ToolOutput { active, .. } => *active = false,
                _ => {}
            }
        }
    }

    pub fn input_insert(&mut self, ch: char) {
        self.paste_line_count = None;
        let pos = self
            .input
            .char_indices()
            .nth(self.cursor)
            .map(|(i, _)| i)
            .unwrap_or(self.input.len());
        self.input.insert(pos, ch);
        self.cursor += 1;
    }

    pub fn input_backspace(&mut self) {
        self.paste_line_count = None;
        if self.cursor > 0 {
            if let Some((pos, _)) = self.input.char_indices().nth(self.cursor - 1) {
                self.input.remove(pos);
                self.cursor -= 1;
            }
        }
    }

    pub fn input_delete(&mut self) {
        self.paste_line_count = None;
        let len = self.input.chars().count();
        if self.cursor < len {
            if let Some((pos, _)) = self.input.char_indices().nth(self.cursor) {
                self.input.remove(pos);
            }
        }
    }

    pub fn input_take(&mut self) -> String {
        self.cursor = 0;
        self.paste_line_count = None;
        std::mem::take(&mut self.input)
    }

    pub fn history_push(&mut self, text: &str) {
        let text = text.trim().to_string();
        if text.is_empty() { return; }
        if self.input_history.last().map(|s| s == &text).unwrap_or(false) { return; }
        self.input_history.push(text);
        if self.input_history.len() > 200 { self.input_history.remove(0); }
        self.history_idx = None;
    }

    pub fn history_prev(&mut self) {
        if self.input_history.is_empty() { return; }
        match self.history_idx {
            None => {
                self.input_saved = self.input.clone();
                let idx = self.input_history.len() - 1;
                self.history_idx = Some(idx);
                self.input = self.input_history[idx].clone();
                self.cursor = self.input.chars().count();
            }
            Some(0) => {}
            Some(idx) => {
                let new = idx - 1;
                self.history_idx = Some(new);
                self.input = self.input_history[new].clone();
                self.cursor = self.input.chars().count();
            }
        }
    }

    pub fn history_next(&mut self) {
        match self.history_idx {
            None => {}
            Some(idx) if idx + 1 >= self.input_history.len() => {
                self.history_idx = None;
                self.input = std::mem::take(&mut self.input_saved);
                self.cursor = self.input.chars().count();
            }
            Some(idx) => {
                let new = idx + 1;
                self.history_idx = Some(new);
                self.input = self.input_history[new].clone();
                self.cursor = self.input.chars().count();
            }
        }
    }
}

// ── Word-boundary helpers ─────────────────────────────────────────────────────

fn word_left(input: &str, cursor: usize) -> usize {
    if cursor == 0 { return 0; }
    let chars: Vec<char> = input.chars().collect();
    let mut pos = cursor - 1;
    while pos > 0 && chars[pos].is_whitespace() { pos -= 1; }
    while pos > 0 && !chars[pos - 1].is_whitespace() { pos -= 1; }
    pos
}

fn word_right(input: &str, cursor: usize) -> usize {
    let chars: Vec<char> = input.chars().collect();
    let len = chars.len();
    if cursor >= len { return len; }
    let mut pos = cursor;
    while pos < len && !chars[pos].is_whitespace() { pos += 1; }
    while pos < len && chars[pos].is_whitespace() { pos += 1; }
    pos
}

// ── Events ────────────────────────────────────────────────────────────────────

pub enum TuiEvent {
    Key(KeyEvent),
    AgentEvent(AssistantEvent),
    Tick,
    /// Main thread needs terminal for a slash command — TUI yields and waits.
    Suspend { ack: std::sync::mpsc::SyncSender<()> },
    Resume,
    Quit,
    /// Exit and show the session report card.
    QuitWithReport,
    /// Voice transcription result — insert this text at the cursor.
    VoiceText(String),
    /// Voice transcription failed — show the error message.
    VoiceError(String),
    /// Transcription is in progress — show "Transcribing…" status.
    VoiceTranscribing,
    /// Bracketed paste — insert without triggering submit on newlines.
    PasteText(String),
    /// Mouse wheel scroll up — scroll content up (older messages).
    ScrollUp,
    /// Mouse wheel scroll down — scroll content down (newer messages).
    ScrollDown,

    // ── Tool Approval (HITL) ───────────────────────────────────────────────
    /// Agent needs permission to execute a tool (Sync bridge).
    ToolApprovalRequestSync {
        id: String,
        name: String,
        input: serde_json::Value,
        tx: std::sync::mpsc::SyncSender<runtime::PermissionPromptDecision>,
        /// Pre-selected option: 0=Allow once, 1=Allow session, 2=Suggest changes, 3=Deny.
        /// Prompt mode sends 0; ReadOnly/WorkspaceWrite send 3 so Deny is highlighted by default.
        default_selected: usize,
    },
    /// User responded to a tool approval request.
    ToolApprovalResponse {
        approved: bool,
        feedback: Option<String>,
    },
}

// ── Popup items ───────────────────────────────────────────────────────────────

#[derive(Clone)]
struct PopupItem {
    display: String,
    complete: String,
    desc: String,
    /// Category header row — not selectable, rendered differently.
    is_header: bool,
}

impl PopupItem {
    fn cmd(display: &str, complete: &str, desc: &str) -> Self {
        Self { display: display.to_string(), complete: complete.to_string(), desc: desc.to_string(), is_header: false }
    }
    fn header(label: &str) -> Self {
        Self { display: label.to_string(), complete: String::new(), desc: String::new(), is_header: true }
    }
}

// Command groups for the categorised root view (shown when input == "/")
const CMD_GROUPS: &[(&str, &[&str])] = &[
    ("CONFIG",    &["model", "permissions", "auth"]),
    ("SESSION",   &["status", "compact", "compress", "clear", "cost", "export", "session", "resume"]),
    ("GIT",       &["commit", "pr", "issue", "diff"]),
    ("AGENT",     &["plan", "loop", "tdd", "verify", "code-review", "build-fix", "bughunter", "ultraplan", "refactor"]),
    ("WORKSPACE", &["init", "memory", "config", "docs", "learn", "checkpoint", "aside", "teleport", "debug-tool-call"]),
    ("INFO",      &["help", "version"]),
];

/// Commands that open a sub-menu when Enter is pressed (rather than submitting directly).
/// Enter → fills input with "/cmd " → popup re-renders the sub-options.
fn is_drilldown(complete: &str) -> bool {
    matches!(complete, "/model" | "/permissions" | "/auth")
}

// All supported auth providers (id, description)
const AUTH_PROVIDERS: &[(&str, &str)] = &[
    // ── First-party ───────────────────────────────────────────────────────────
    ("anthropic",     "Claude opus-4-7 · sonnet-4-6 · haiku-4-5"),
    ("openai",        "GPT-4o · GPT-4o-mini · o3 · o3-mini"),
    ("google",        "Gemini 2.5 Pro · Flash · Flash 8B"),
    ("xai",           "Grok 3 · Grok 3-mini"),
    // ── Fast inference ────────────────────────────────────────────────────────
    ("groq",          "Llama 3.3 70B · 8B — ultra-fast LPU"),
    ("cerebras",      "Llama 3.3 70B on WSE accelerator"),
    // ── Commercial clouds ─────────────────────────────────────────────────────
    ("deepseek",      "DeepSeek V3 · R1 chain-of-thought"),
    ("mistral",       "Mistral Large · Small · Codestral"),
    ("cohere",        "Command R+ · Command R — RAG"),
    ("perplexity",    "Sonar Pro · Sonar — search-grounded"),
    ("openrouter",    "100+ models via unified API"),
    // ── GPU inference clouds ──────────────────────────────────────────────────
    ("together",      "Open source models at scale"),
    ("fireworks",     "Fast inference — Llama, Mistral, …"),
    ("novita",        "Cost-efficient open model hosting"),
    ("deepinfra",     "Low-cost inference API"),
    ("sambanova",     "High-throughput RDU chips"),
    ("nvidia",        "NVIDIA NIM — 80+ models, free tier available"),
    // ── Regional foundation models ────────────────────────────────────────────
    ("zhipu",         "Z.AI GLM-4.5 · GLM-5 — Global / CN"),
    ("minimax",       "MiniMax-Text-01"),
    ("qwen",          "Qwen2.5 · QwQ-32B — Alibaba"),
    ("moonshot",      "Kimi K2.5 · 128k context"),
    ("qianfan",       "Ernie 4.5 — Baidu"),
    // ── Inference marketplaces ────────────────────────────────────────────────
    ("chutes",        "DeepSeek R1/V3 · Qwen — marketplace"),
    ("huggingface",   "Open model inference — HF Hub"),
    ("github",        "GitHub Copilot models — GPT-4o · o3"),
    // ── Enterprise ───────────────────────────────────────────────────────────
    ("azure",         "Azure OpenAI — bring your endpoint"),
    // ── Local / offline ───────────────────────────────────────────────────────
    ("ollama",        "Local models — no API key required"),
    ("lmstudio",      "LM Studio — local GUI · localhost:1234"),
    ("openai-compat", "Any OpenAI-compatible base URL"),
];

// @ Mentions / Agents
const AGENT_GROUPS: &[(&str, &[(&str, &str)])] = &[
    ("AGENTS", &[
        ("plan",    "Break down complex tasks into steps"),
        ("loop",    "Autonomous execution autopilot"),
        ("tdd",     "Strict Test-Driven Development"),
        ("verify",  "Full workspace health check"),
        ("debug",   "Deep root-cause analysis"),
        ("fix",     "Autonomous bug resolution"),
        ("review",  "Deep security & logic review"),
    ]),
    ("RULES", &[
        ("strict",  "Enforce maximum safety and types"),
        ("fast",    "Prioritize speed and brevity"),
        ("debug",   "Verbose logging and tool traces"),
    ]),
];

/// Auth-flow-aware popup: call this everywhere instead of popup_items directly.
/// - Key phase    → no popup (input is masked)
/// - Model phase  → filtered model list from the live auth cache
/// - No auth      → normal slash-command popup
fn state_popup_items(state: &TuiState) -> Vec<PopupItem> {
    match &state.auth_flow {
        Some(AuthFlowPhase::Key { .. }) => vec![],
        Some(AuthFlowPhase::Model { models, .. }) => {
            let partial = state.input.trim().to_lowercase();
            let mut result = vec![PopupItem::header("Select model")];
            for (i, id) in models.iter().enumerate() {
                if !partial.is_empty() {
                    let idx_match = (i + 1).to_string().starts_with(&partial);
                    let id_match = id.to_lowercase().contains(&partial);
                    if !idx_match && !id_match { continue; }
                }
                let desc = api::model_annotation(id).unwrap_or("");
                result.push(PopupItem::cmd(id, id, desc));
            }
            result
        }
        None => popup_items(&state.input, state.model_list_cache.as_deref()),
    }
}

/// Returns popup items for the current input:
///   /              → full categorised command list
///   @              → agent/rule picker
///   /partial       → flat filtered list
///   /permissions   → permission mode picker
///   /model         → model picker (live cache if available, else curated)
///   /auth          → provider picker
fn popup_items(input: &str, model_cache: Option<&[String]>) -> Vec<PopupItem> {
    if input.starts_with('@') {
        let partial = &input[1..];
        let mut items = Vec::new();
        for (label, agents) in AGENT_GROUPS {
            let matches: Vec<PopupItem> = agents.iter()
                .filter(|(name, _)| partial.is_empty() || name.starts_with(partial))
                .map(|(name, desc)| PopupItem::cmd(
                    &format!("@{name}"),
                    &format!("/{name}"), // map to slash command for execution
                    desc,
                ))
                .collect();
            if !matches.is_empty() {
                items.push(PopupItem::header(label));
                items.extend(matches);
            }
        }
        return items;
    }

    if !input.starts_with('/') {
        return vec![];
    }

    // ── Permission mode picker ─────────────────────────────────────────────
    if input.starts_with("/permissions") {
        let partial = input.strip_prefix("/permissions").unwrap_or("").trim();
        return PERM_MODES
            .iter()
            .filter(|(mode, _)| partial.is_empty() || mode.starts_with(partial))
            .map(|(mode, desc)| PopupItem::cmd(
                &format!("permissions  {mode}"),
                &format!("/permissions {mode}"),
                desc,
            ))
            .collect();
    }

    // ── Model picker ──────────────────────────────────────────────────────
    if input.starts_with("/model") {
        let partial = input.strip_prefix("/model").unwrap_or("").trim();
        // If we have a live cache from the active provider, show it.
        if let Some(cached) = model_cache {
            let mut items: Vec<PopupItem> = Vec::new();
            items.push(PopupItem::header("Active provider — live"));
            for id in cached {
                if !partial.is_empty() && !id.contains(partial) { continue; }
                let desc = api::model_annotation(id).unwrap_or("");
                items.push(PopupItem::cmd(id, &format!("/model {id}"), desc));
            }
            return items;
        }
        // Fall back to curated cross-provider list.
        let mut items: Vec<PopupItem> = Vec::new();
        let mut cur_provider = "";
        for (id, provider, desc) in MODEL_ENTRIES {
            if !partial.is_empty() && !id.contains(partial) && !provider.to_lowercase().contains(partial) {
                continue;
            }
            if *provider != cur_provider {
                items.push(PopupItem::header(provider));
                cur_provider = provider;
            }
            items.push(PopupItem::cmd(
                id,
                &format!("/model {id}"),
                desc,
            ));
        }
        return items;
    }

    // ── Auth provider picker ───────────────────────────────────────────────
    if input.starts_with("/auth") {
        let partial = input.strip_prefix("/auth").unwrap_or("").trim();
        return AUTH_PROVIDERS
            .iter()
            .filter(|(p, _)| partial.is_empty() || p.starts_with(partial))
            .map(|(provider, desc)| PopupItem::cmd(
                &format!("auth  {provider}"),
                &format!("/auth {provider}"),
                desc,
            ))
            .collect();
    }

    let prefix = &input[1..]; // text after the "/"

    // ── Root view: type "/" alone → categorised full list ─────────────────
    if prefix.is_empty() {
        let specs = slash_command_specs();
        let mut items: Vec<PopupItem> = Vec::new();
        for (label, names) in CMD_GROUPS {
            let group_items: Vec<PopupItem> = names
                .iter()
                .filter_map(|&n| specs.iter().find(|s| s.name == n))
                .map(|s| {
                    // Drill-down commands show "›" hint; others show their argument hint.
                    let hint = if is_drilldown(&format!("/{}", s.name)) {
                        "  ›".to_string()
                    } else {
                        s.argument_hint.map(|h| format!(" {h}")).unwrap_or_default()
                    };
                    PopupItem::cmd(
                        &format!("{}{hint}", s.name),
                        &format!("/{}", s.name),
                        s.summary,
                    )
                })
                .collect();
            if !group_items.is_empty() {
                items.push(PopupItem::header(label));
                items.extend(group_items);
            }
        }
        return items;
    }

    // ── Prefix search: flat filtered list ─────────────────────────────────
    slash_command_specs()
        .iter()
        .filter(|s| s.name.starts_with(prefix))
        .map(|s| {
            let hint = if is_drilldown(&format!("/{}", s.name)) {
                "  ›".to_string()
            } else {
                s.argument_hint.map(|h| format!(" {h}")).unwrap_or_default()
            };
            PopupItem::cmd(
                &format!("{}{hint}", s.name),
                &format!("/{}", s.name),
                s.summary,
            )
        })
        .collect()
}

// ── Tool preview ──────────────────────────────────────────────────────────────

/// Extract a clean human-readable preview from a tool's JSON input.
pub fn tool_input_preview(input: &str) -> String {
    const MAX: usize = 90;

    if let Ok(val) = serde_json::from_str::<serde_json::Value>(input) {
        // Ordered priority: first matching non-empty string key wins
        let priority = [
            "command", "path", "file_path", "pattern", "query",
            "url", "prompt", "text", "content",
        ];
        for key in &priority {
            if let Some(s) = val.get(key).and_then(|v| v.as_str()) {
                let s = s.trim();
                if !s.is_empty() {
                    return truncate(s, MAX);
                }
            }
        }
        // Fallback: first string value in the object
        if let Some(obj) = val.as_object() {
            for (_, v) in obj {
                if let Some(s) = v.as_str() {
                    let s = s.trim();
                    if !s.is_empty() {
                        return truncate(s, MAX);
                    }
                }
            }
        }
    }

    truncate(input.trim(), MAX)
}

fn truncate(s: &str, max_chars: usize) -> String {
    let count = s.chars().count();
    if count <= max_chars {
        return s.to_string();
    }
    let end = s.char_indices().nth(max_chars).map(|(i, _)| i).unwrap_or(s.len());
    format!("{}…", &s[..end])
}

fn generate_repo_map(root: &std::path::Path, max_depth: usize) -> String {
    use walkdir::WalkDir;
    let mut map = String::new();
    let root_str = root.file_name().and_then(|n| n.to_str()).unwrap_or(".");
    map.push_str(&format!("{}/\n", root_str));

    fn is_noise(name: &str) -> bool {
        let n = name.to_lowercase();
        // Common build/cache artifacts
        let noise_dirs = ["target", "node_modules", "dist", "build", "out", "debug", "release", ".git", ".cache", ".next", ".cargo", "vendor"];
        if noise_dirs.iter().any(|&d| n == d) { return true; }
        
        // Long alphanumeric hash names (e.g. 3a5b6c...)
        if name.len() > 20 && name.chars().all(|c| c.is_alphanumeric()) { return true; }
        
        false
    }

    let mut it = WalkDir::new(root)
        .min_depth(1)
        .max_depth(max_depth)
        .sort_by(|a, b| {
            let a_is_dir = a.file_type().is_dir();
            let b_is_dir = b.file_type().is_dir();
            if a_is_dir != b_is_dir {
                // Directories first
                b_is_dir.cmp(&a_is_dir)
            } else {
                a.file_name().cmp(b.file_name())
            }
        })
        .into_iter()
        .filter_entry(|e| !is_noise(e.file_name().to_str().unwrap_or("")))
        .peekable();

    let mut count_at_depth = std::collections::HashMap::new();

    while let Some(Ok(entry)) = it.next() {
        let depth = entry.depth();
        let name = entry.file_name().to_string_lossy();
        let is_dir = entry.file_type().is_dir();

        // Count items at this depth for truncation logic
        let count = count_at_depth.entry(depth).or_insert(0);
        *count += 1;

        if !is_dir && *count > 5 {
            // Check if there are more items at this depth to show "and X more"
            let mut more = 0;
            while let Some(Ok(peek)) = it.peek() {
                if peek.depth() == depth {
                    more += 1;
                    it.next();
                } else {
                    break;
                }
            }
            
            let mut indent = String::new();
            for _ in 1..depth { indent.push_str("│   "); }
            if more > 0 {
                map.push_str(&format!("└── ... and {} more items\n", more));
            }
            continue;
        }

        let mut indent = String::new();
        for _ in 1..depth {
            indent.push_str("│   ");
        }

        // We can't easily know if it's the absolute last item without more complex lookahead,
        // but ├── is a safe default for a compressed view.
        let connector = "├── ";
        map.push_str(&format!("{}{}{}\n", indent, connector, name));
    }

    map
}

// ── Markdown rendering ────────────────────────────────────────────────────────

/// Convert a markdown string to styled ratatui Lines, wrapping manually to fit within width.
fn markdown_to_lines(text: &str, prefix: Option<Span<'static>>, width: u16) -> Vec<Line<'static>> {
    let mut lines: Vec<Line<'static>> = Vec::new();
    let mut spans: Vec<Span<'static>> = Vec::new();
    let mut bold = false;
    let mut italic = false;
    let mut in_code_block = false;
    let mut in_heading = false;
    let mut heading_color = FG;
    let mut list_depth: usize = 0;
    let mut item_needs_bullet = false;

    let prefix_w = prefix.as_ref().map(|p| p.content.chars().count()).unwrap_or(0) as u16;
    let available_w = width.saturating_sub(prefix_w + 1).max(10);

    let opts = MdOptions::ENABLE_STRIKETHROUGH;
    let parser = MdParser::new_ext(text, opts);

    let flush_to_lines = |spans: &mut Vec<Span<'static>>, lines: &mut Vec<Line<'static>>| {
        if spans.is_empty() { return; }
        
        // Manual wrapping: convert spans to a single string, wrap it, then recreate spans.
        let mut full_text = String::new();
        for s in spans.iter() { full_text.push_str(&s.content); }
        
        // Very simple wrap: split by available_w
        let mut current_pos = 0;
        let chars: Vec<char> = full_text.chars().collect();
        while current_pos < chars.len() {
            let end = (current_pos + available_w as usize).min(chars.len());
            // Try to find a space to wrap at
            let mut wrap_at = end;
            if end < chars.len() {
                for i in (current_pos..end).rev() {
                    if chars[i].is_whitespace() {
                        wrap_at = i + 1;
                        break;
                    }
                }
            }
            
            let chunk: String = chars[current_pos..wrap_at].iter().collect();
            let mut row = Vec::new();
            if let Some(p) = &prefix { row.push(p.clone()); }
            row.push(Span::styled(chunk, Style::default().fg(FG))); // Simplification: lose internal formatting on wrap for now
            lines.push(Line::from(row));
            
            current_pos = wrap_at;
            while current_pos < chars.len() && chars[current_pos].is_whitespace() && chars[current_pos] != '\n' {
                current_pos += 1;
            }
        }
        spans.clear();
    };

    for event in parser {
        match event {
            MdEvent::Start(Tag::Heading { level, .. }) => {
                in_heading = true;
                heading_color = match level {
                    HeadingLevel::H1 => GREEN,
                    HeadingLevel::H2 => CYAN,
                    _ => FG,
                };
            }
            MdEvent::End(TagEnd::Heading(_)) => {
                flush_to_lines(&mut spans, &mut lines);
                in_heading = false;
            }
            MdEvent::Start(Tag::Strong) => bold = true,
            MdEvent::End(TagEnd::Strong) => bold = false,
            MdEvent::Start(Tag::Emphasis) => italic = true,
            MdEvent::End(TagEnd::Emphasis) => italic = false,
            MdEvent::Start(Tag::CodeBlock(_)) => in_code_block = true,
            MdEvent::End(TagEnd::CodeBlock) => {
                flush_to_lines(&mut spans, &mut lines);
                lines.push(if let Some(p) = &prefix { Line::from(vec![p.clone()]) } else { Line::default() });
                in_code_block = false;
            }
            MdEvent::Start(Tag::List(_)) => list_depth += 1,
            MdEvent::End(TagEnd::List(_)) => list_depth = list_depth.saturating_sub(1),
            MdEvent::Start(Tag::Item) => item_needs_bullet = true,
            MdEvent::End(TagEnd::Item) => flush_to_lines(&mut spans, &mut lines),
            MdEvent::Start(Tag::Paragraph) => {}
            MdEvent::End(TagEnd::Paragraph) => {
                flush_to_lines(&mut spans, &mut lines);
                lines.push(if let Some(p) = &prefix { Line::from(vec![p.clone()]) } else { Line::default() });
            }
            MdEvent::Text(t) => {
                if item_needs_bullet {
                    item_needs_bullet = false;
                    let indent = "  ".repeat(list_depth); // 2 spaces per depth
                    spans.push(Span::styled(format!("{indent}• "), Style::default().fg(DIM)));
                }
                if in_code_block {
                    for line in t.lines() {
                        let mut row = Vec::new();
                        if let Some(p) = &prefix { row.push(p.clone()); }
                        row.push(Span::styled("▌", Style::default().fg(CODE_BAR).bg(CODE_BG)));
                        row.push(Span::styled(format!(" {line}"), Style::default().fg(CODE_FG).bg(CODE_BG)));
                        lines.push(Line::from(row));
                    }
                } else {
                    let mut style = Style::default().fg(FG);
                    if in_heading { style = style.fg(heading_color).add_modifier(Modifier::BOLD); }
                    else {
                        if bold { style = style.add_modifier(Modifier::BOLD); }
                        if italic { style = style.add_modifier(Modifier::ITALIC); }
                    }
                    spans.push(Span::styled(t.to_string(), style));
                }
            }
            MdEvent::Code(c) => {
                spans.push(Span::styled(format!("`{c}`"), Style::default().fg(CODE_FG)));
            }
            MdEvent::SoftBreak => { spans.push(Span::styled(" ".to_string(), Style::default().fg(FG))); }
            MdEvent::HardBreak => flush_to_lines(&mut spans, &mut lines),
            MdEvent::Rule => {
                flush_to_lines(&mut spans, &mut lines);
                let mut row = Vec::new();
                if let Some(p) = &prefix { row.push(p.clone()); }
                row.push(Span::styled("─".repeat(available_w as usize), Style::default().fg(DIM)));
                lines.push(Line::from(row));
            }
            _ => {}
        }
    }
    flush_to_lines(&mut spans, &mut lines);

    // Post-processing: Remove trailing empty/spine-only lines to avoid "ghost padding"
    while let Some(last) = lines.last() {
        let is_empty = last.spans.is_empty();
        let is_spine_only = prefix.as_ref().map(|p| {
            last.spans.len() == 1 && last.spans[0].content == p.content
        }).unwrap_or(false);

        if is_empty || is_spine_only {
            lines.pop();
        } else {
            break;
        }
    }
    lines
}

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

pub fn render(f: &mut ratatui::Frame, state: &TuiState) {
    let area = f.area();
    let items = state_popup_items(state);
    let n_items = items.len();
    // Popup: up to POPUP_WINDOW items + 1 nav footer; placed BELOW input (Gemini-style)
    let popup_h = if n_items == 0 { 0u16 } else { (n_items.min(POPUP_WINDOW) + 1) as u16 };

    let input_h = {
        let badge_approx = git_branch_cached()
            .map(|b| b.chars().count() as u16 + 2)
            .unwrap_or(0);
        // usable width for text content (minus borders and badge)
        let w = area.width.saturating_sub(2 + badge_approx).max(10) as usize;
        let p_len = 3; // " ≻ " or " ↑ "
        let n = if state.paste_line_count.is_some() { 1 } else { state.input.chars().count() };
        let text_rows = if n == 0 || state.paste_line_count.is_some() {
            1
        } else if n <= w - p_len {
            1
        } else {
            let rem = n - (w - p_len);
            1 + (rem + w - 1) / w
        };
        (text_rows as u16 + 2).min(12) // caps total input box height
    };

    // HITL panel height — 7 rows when active (preview + 4 options + nav hint + border)
    let hitl_h: u16 = if state.awaiting_tool_approval.is_some() { 7 } else { 0 };

    // Layout top→bottom: content(flex) | status(1r) | input(dynamic) | [popup?] | [hitl?] | tips(1r) | footer(1r)
    let mut constraints = vec![
        Constraint::Min(3),
        Constraint::Length(1),        // status strip (always visible)
        Constraint::Length(input_h),  // expanding input
    ];
    // Slash popup and HITL panel are mutually exclusive — HITL wins.
    if hitl_h == 0 && popup_h > 0 { constraints.push(Constraint::Length(popup_h)); }
    if hitl_h > 0 { constraints.push(Constraint::Length(hitl_h)); }
    constraints.push(Constraint::Length(1)); // rotating tip row
    constraints.push(Constraint::Length(1)); // footer

    let layout = Layout::default()
        .direction(Direction::Vertical)
        .constraints(constraints)
        .split(area);

    let mut idx = 0usize;
    render_content(f, layout[idx], state);
    idx += 1;
    render_status(f, layout[idx], state);
    idx += 1;
    render_input(f, layout[idx], state);
    idx += 1;
    if hitl_h == 0 && popup_h > 0 {
        let sel = state.popup_selected.min(n_items.saturating_sub(1));
        render_popup(f, layout[idx], &items, sel);
        idx += 1;
    }
    if hitl_h > 0 {
        if let Some(approval_arc) = &state.awaiting_tool_approval {
            if let Some(approval) = &*approval_arc.lock().unwrap_or_else(|p| p.into_inner()) {
                render_hitl_panel(f, layout[idx], &approval.name, &approval.input, state.hitl_selected);
            }
        }
        idx += 1;
    }
    render_tips(f, layout[idx], state);
    idx += 1;
    render_footer(f, layout[idx], state);
    // Help overlay floats on top of everything — rendered last so it covers all other widgets.
    if state.help_open {
        render_help_overlay(f, area, state.help_scroll);
    }
}

fn is_last_in_turn_by_index(log: &std::collections::VecDeque<ExecBlock>, start_idx: usize) -> bool {
    for (_idx, block) in log.iter().enumerate().skip(start_idx + 1) {
        match block {
            ExecBlock::UserMessage(_) => return true,
            ExecBlock::ToolUse { .. } | ExecBlock::Plan { .. } | ExecBlock::ToolOutput { .. } | ExecBlock::AgentText(..) => return false,
            ExecBlock::WorkedFor(_) | ExecBlock::SystemMsg(_) | ExecBlock::Thinking(_) => continue,
        }
    }
    true
}

fn build_exec_lines(state: &TuiState, _width: u16) -> Vec<Line<'static>> {
    let mut lines: Vec<Line<'static>> = Vec::new();
    let mut in_assistant_turn = false;

    // Define consistent spacing for the "Laminar Flow" architecture.
    // Spine at Col 0, Hook at Col 2, Dot at Col 4.
    // Animate the spine glyph at 2Hz when working (every 5 ticks @ 100ms = 500ms per step).
    const SPINE_GLYPHS: [&str; 4] = ["│ ", "╎ ", "┆ ", "┊ "];
    let spine_glyph = if state.working {
        SPINE_GLYPHS[((state.spine_frame / 5) % 4) as usize]
    } else {
        "│ "
    };
    let spine = Span::styled(spine_glyph, Style::default().fg(Color::Rgb(25, 45, 45)));
    let seal = Span::styled("└─", Style::default().fg(Color::Rgb(25, 45, 45)));

    for (block_idx, block) in state.exec_log.iter().enumerate() {
        let is_last = is_last_in_turn_by_index(&state.exec_log, block_idx);
        let is_collapsed = state.collapsed_blocks.contains(&block_idx);

        match block {
            ExecBlock::UserMessage(msg) => {
                // Remove redundant Line::default() - vertical flow handled by constraints
                lines.push(Line::from(vec![
                    Span::styled(
                        "≻ ",
                        Style::default().fg(CYAN).add_modifier(Modifier::BOLD),
                    ),
                    Span::styled(msg.clone(), Style::default().fg(FG).bg(USER_BOX_BG)),
                ]));
                in_assistant_turn = false;
            }

            ExecBlock::ToolUse { name, args, active, xray } => {
                if !in_assistant_turn {
                    lines.push(Line::from(vec![
                        Span::styled("albert", Style::default().fg(Color::Rgb(0, 170, 120)).add_modifier(Modifier::BOLD)),
                        Span::styled(" ─────────────────────", Style::default().fg(Color::Rgb(25, 45, 45))),
                    ]));
                    in_assistant_turn = true;
                }

                let elapsed = state.session_start.elapsed().as_secs_f32();
                let dot_style = get_pulse_style(elapsed, *active);
                let (name_col, args_col) = if *active { (FG, CYAN) } else { (GREY, GREY) };

                let verb = if name.contains("write") { "Wrote" }
                else if name.contains("read") { "Read" }
                else if name.contains("grep") || name.contains("search") { "Searched" }
                else if name.contains("glob") || name.contains("scan") { "Scanned" }
                else if name.contains("bash") || name.contains("execute") { "Ran" }
                else if name.contains("plan") { "Planned" }
                else if name.contains("fetch") { "Fetched" }
                else if name.contains("edit") || name.contains("patch") { "Edited" }
                else { "Used" };

                // Peek ahead to see if there's a collapsed ToolOutput following this ToolUse.
                let has_xray = xray.is_some();
                let next_is_tool_output = state.exec_log.get(block_idx + 1)
                    .map(|b| matches!(b, ExecBlock::ToolOutput { active: false, .. }))
                    .unwrap_or(false);
                let has_collapsed_output = !has_xray && next_is_tool_output;
                let hook = if is_last && !has_collapsed_output && !has_xray { "└─" } else { "├─" };

                // Header line
                let dot_icon = if !*active && has_xray { " ✓ " } else { " ● " };
                let dot_style_h = if !*active && has_xray {
                    Style::default().fg(Color::Rgb(0, 200, 100)).add_modifier(Modifier::BOLD)
                } else { dot_style };

                let mut header_spans = vec![
                    spine.clone(),
                    Span::styled(hook, Style::default().fg(Color::Rgb(25, 45, 45))),
                    Span::styled(dot_icon, dot_style_h),
                    Span::styled(format!("{verb} {name}"), Style::default().fg(name_col).add_modifier(Modifier::BOLD)),
                ];

                if has_xray {
                    if let Some(xr) = xray {
                        let summary = if !*active {
                            format!("  {} → Accepted (+{}, -{})",
                                xr.file, xr.added, xr.removed)
                        } else {
                            format!("  {}", xr.file)
                        };
                        header_spans.push(Span::styled(summary, Style::default().fg(Color::Rgb(0, 180, 120))));
                    }
                } else {
                    if !args.is_empty() {
                        header_spans.push(Span::styled(format!("  {args}"), Style::default().fg(args_col)));
                    }
                    if has_collapsed_output {
                        header_spans.push(Span::styled(" [collapsed]", Style::default().fg(DIM).add_modifier(Modifier::ITALIC)));
                    }
                }
                lines.push(Line::from(header_spans));

                // XRay diff lines (only when not collapsed / has xray)
                if let Some(xr) = xray {
                    const XRAY_BG_ADD: Color = Color::Rgb(0, 35, 15);
                    const XRAY_BG_REM: Color = Color::Rgb(40, 8, 8);
                    const XRAY_FG_ADD: Color = Color::Rgb(80, 230, 120);
                    const XRAY_FG_REM: Color = Color::Rgb(230, 80, 80);
                    const XRAY_FG_CTX: Color = Color::Rgb(70, 85, 85);
                    const XRAY_FG_NUM: Color = Color::Rgb(55, 75, 75);

                    let usable_w = _width.saturating_sub(10).max(20) as usize;

                    for xline in &xr.lines {
                        match xline {
                            XRayLine::Elided { count } => {
                                lines.push(Line::from(vec![
                                    spine.clone(),
                                    Span::styled(
                                        format!("  ···  {} lines unchanged", count),
                                        Style::default().fg(DIM).add_modifier(Modifier::ITALIC),
                                    ),
                                ]));
                            }
                            XRayLine::Context { n, text } => {
                                let display = format!("{:>4}   {}", n, &text.chars().take(usable_w).collect::<String>());
                                lines.push(Line::from(vec![
                                    spine.clone(),
                                    Span::styled(display, Style::default().fg(XRAY_FG_CTX)),
                                ]));
                            }
                            XRayLine::Removed { n, text } => {
                                let num_s = Span::styled(format!("{:>4} ", n), Style::default().fg(XRAY_FG_NUM).bg(XRAY_BG_REM));
                                let prefix = Span::styled("- ", Style::default().fg(XRAY_FG_REM).bg(XRAY_BG_REM).add_modifier(Modifier::BOLD));
                                let content = Span::styled(
                                    text.chars().take(usable_w).collect::<String>(),
                                    Style::default().fg(XRAY_FG_REM).bg(XRAY_BG_REM),
                                );
                                lines.push(Line::from(vec![spine.clone(), num_s, prefix, content]));
                            }
                            XRayLine::Added { n, text } => {
                                let num_s = Span::styled(format!("{:>4} ", n), Style::default().fg(XRAY_FG_NUM).bg(XRAY_BG_ADD));
                                let prefix = Span::styled("+ ", Style::default().fg(XRAY_FG_ADD).bg(XRAY_BG_ADD).add_modifier(Modifier::BOLD));
                                let content = Span::styled(
                                    text.chars().take(usable_w).collect::<String>(),
                                    Style::default().fg(XRAY_FG_ADD).bg(XRAY_BG_ADD),
                                );
                                lines.push(Line::from(vec![spine.clone(), num_s, prefix, content]));
                            }
                        }
                    }
                    // Seal after diff
                    if is_last {
                        lines.push(Line::from(vec![seal.clone()]));
                    }
                }
            }

            ExecBlock::Plan { tasks, frozen } => {
                if !in_assistant_turn {
                    lines.push(Line::default());
                    lines.push(Line::from(vec![
                        Span::styled("albert", Style::default().fg(Color::Rgb(0, 170, 120)).add_modifier(Modifier::BOLD)),
                        Span::styled(" ─────────────────────", Style::default().fg(Color::Rgb(25, 45, 45))),
                    ]));
                    in_assistant_turn = true;
                }

                let elapsed = state.session_start.elapsed().as_secs_f32();
                for (i, task) in tasks.iter().enumerate() {
                    let is_final_task = is_last && i == tasks.len() - 1;
                    let hook = if is_final_task { "└─" } else { "├─" };
                    
                    let (icon, style) = match task.status {
                        TaskStatus::Pending => (" [ ] ", Style::default().fg(GREY)),
                        TaskStatus::Running => {
                            if *frozen { (" [●] ", Style::default().fg(GREEN)) }
                            else { (" [●] ", get_pulse_style(elapsed, true)) }
                        }
                        TaskStatus::Done => (" [✔] ", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
                        TaskStatus::Failed => (" [✘] ", Style::default().fg(ERROR_FG).add_modifier(Modifier::BOLD)),
                    };
                    // Audit: Single spine at Col 0.
                    lines.push(Line::from(vec![
                        spine.clone(),
                        Span::styled(hook, Style::default().fg(Color::Rgb(25, 45, 45))),
                        Span::styled(icon, style),
                        Span::styled(task.label.clone(), Style::default().fg(FG)),
                    ]));
                }
            }

            ExecBlock::ToolOutput { lines: out, total, active } => {
                if is_collapsed && !out.is_empty() {
                    // Collapsed view: show hidden line count + brief summary
                    lines.push(Line::from(vec![
                        spine.clone(),
                        Span::styled("└─", Style::default().fg(Color::Rgb(25, 45, 45))),
                        Span::styled(" ", Style::default()),
                        Span::styled(format!("... first {} lines hidden (Ctrl+O to show) ...", out.len()),
                            Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
                    ]));

                    // Try to extract a result summary (first line that looks like "Result:" or similar)
                    if let Some(result_line) = out.iter().find(|l| l.contains("Result:") || l.contains("result:")) {
                        lines.push(Line::from(vec![
                            spine.clone(),
                            Span::styled("   ", Style::default()),
                            Span::styled(result_line.clone(), Style::default().fg(Color::Rgb(110, 120, 120))),
                        ]));
                    }

                    if *total > out.len() {
                        lines.push(Line::from(vec![
                            spine.clone(),
                            Span::styled("   ", Style::default()),
                            Span::styled(format!("… +{} more lines", total - out.len()),
                                Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
                        ]));
                    }

                    if is_last {
                        lines.push(Line::from(vec![seal.clone()]));
                    }
                } else if *active {
                    for (i, line) in out.iter().enumerate() {
                        let connector = if i == 0 { "└─" } else { "  " };
                        let lower = line.to_ascii_lowercase();
                        let is_err = lower.contains("error") || lower.contains("not found") || lower.contains("failed:");
                        let line_col = if is_err { ERROR_FG } else { Color::Rgb(110, 120, 120) };

                        lines.push(Line::from(vec![
                            spine.clone(), // Keep main spine
                            Span::styled(connector, Style::default().fg(Color::Rgb(25, 45, 45))),
                            Span::styled(" ", Style::default()),
                            Span::styled(line.clone(), Style::default().fg(line_col)),
                        ]));
                    }
                    if *total > out.len() {
                        lines.push(Line::from(vec![
                            spine.clone(),
                            Span::styled("   ", Style::default()),
                            Span::styled(format!("… +{} lines", total - out.len()), Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
                        ]));
                    }
                    if is_last {
                        lines.push(Line::from(vec![seal.clone()]));
                    }
                } else {
                    // Historical Tool Collapse (Accordion Mode)
                    // Skip rendering here as it's now inlined into the parent ToolUse line.
                    if is_last {
                        lines.push(Line::from(vec![seal.clone()]));
                    }
                }
            }

            ExecBlock::AgentText(text, interrupted) => {
                if !in_assistant_turn {
                    lines.push(Line::from(vec![
                        Span::styled("albert", Style::default().fg(Color::Rgb(0, 170, 120)).add_modifier(Modifier::BOLD)),
                        Span::styled(" ─────────────────────", Style::default().fg(Color::Rgb(25, 45, 45))),
                    ]));
                    in_assistant_turn = true;
                }
                
                let text = text.trim();
                let md_lines = markdown_to_lines(text, Some(spine.clone()), _width);
                lines.extend(md_lines);

                if is_last || *interrupted {
                    lines.push(Line::from(vec![seal.clone()]));
                }
            }

            ExecBlock::WorkedFor(_) => {}

            ExecBlock::Thinking(text) => {
                in_assistant_turn = false;
                let thinking_style = Style::default()
                    .fg(Color::Rgb(90, 90, 90))
                    .add_modifier(Modifier::ITALIC | Modifier::DIM);
                let spine_style = Style::default()
                    .fg(Color::Rgb(55, 55, 55))
                    .add_modifier(Modifier::DIM);
                lines.push(Line::from(vec![
                    Span::styled("  thinking", Style::default().fg(Color::Rgb(70, 70, 70)).add_modifier(Modifier::ITALIC | Modifier::DIM)),
                ]));
                for line in text.lines() {
                    lines.push(Line::from(vec![
                        Span::styled("  │ ", spine_style),
                        Span::styled(line.to_string(), thinking_style),
                    ]));
                }
                // Trailing spine while streaming (text empty or ends with newline)
                if text.is_empty() || text.ends_with('\n') {
                    lines.push(Line::from(vec![
                        Span::styled("  │", spine_style),
                    ]));
                }
            }

            ExecBlock::SystemMsg(msg) => {
                let mut it_msg = msg.lines().peekable();
                let first_line = it_msg.peek().cloned().unwrap_or_default();
                let is_banner = first_line == "[BANNER]";
                let is_treemap = first_line == "[TREEMAP]";

                if is_banner {
                    let has_user_msgs = state.exec_log.iter().any(|b| matches!(b, ExecBlock::UserMessage(_)));
                    if has_user_msgs { continue; }
                }

                lines.push(Line::default());
                in_assistant_turn = false;
                if is_banner || is_treemap {
                    it_msg.next(); // skip marker
                    let logo_colors = [
                        Color::Rgb(0, 255, 255), // Turquoise gradient
                        Color::Rgb(0, 220, 255),
                        Color::Rgb(0, 190, 255),
                        Color::Rgb(0, 160, 255),
                        Color::Rgb(0, 130, 255),
                        Color::Rgb(0, 100, 255),
                    ];
                    
                    let banner_lines: Vec<String> = it_msg.map(|s| s.to_string()).collect();
                    let footer_line = banner_lines.iter().position(|l| l.contains("Did you know?")).unwrap_or(banner_lines.len());

                    // 1. Raw String Padding: Rectangularize the logo part
                    let mut padded_logo = Vec::new();
                    let mut max_logo_chars: usize = 0;
                    if is_banner {
                        let logo_count = 6.min(banner_lines.len()).min(footer_line);
                        for i in 0..logo_count {
                            let row = banner_lines[i].chars().take(54).collect::<String>().trim_end().to_string();
                            let c = row.chars().count();
                            if c > max_logo_chars { max_logo_chars = c; }
                            padded_logo.push(row);
                        }
                        for row in &mut padded_logo {
                            let needed = max_logo_chars.saturating_sub(row.chars().count());
                            row.push_str(&" ".repeat(needed));
                        }
                    }

                    // 2. Visual Width Calculation: Find maximum visual width of all content
                    let mut max_visual_w = 51; // Minimum default width
                    for (i, line) in banner_lines.iter().enumerate() {
                        if i >= footer_line { break; }
                        let text = if is_banner && i < padded_logo.len() {
                             padded_logo[i].clone()
                        } else if is_banner {
                             line.chars().take(54).collect::<String>().trim_end().to_string()
                        } else {
                             line.trim_end().to_string()
                        };
                        let w = console::measure_text_width(&text);
                        if w > max_visual_w { max_visual_w = w; }
                    }

                    // 3. Find the Maximum: Add buffer (MAX_WIDTH)
                    let target_w = max_visual_w + 2; // 2 space buffer

                    // Top border (Dynamic Width) - Indented by 1 space for parity with 'albert' header
                    lines.push(Line::from(vec![
                        Span::styled(format!("  ┌{}┐", "─".repeat(target_w + 2)), Style::default().fg(CHAT_BORDER))
                    ]));
                    
                    for (i, line) in banner_lines.iter().enumerate() {
                        if i >= footer_line { break; }
                        
                        let mut row_spans = Vec::new();
                        row_spans.push(Span::styled("  │ ", Style::default().fg(CHAT_BORDER)));

                        let mut current_row_visual_w;

                        if is_treemap {
                            let text = line.trim_end();
                            current_row_visual_w = console::measure_text_width(text);
                            row_spans.push(Span::styled(text.to_string(), Style::default().fg(GREEN)));
                        } else {
                            // Banner logic (Logo/Meta)
                            let content_text = if i < padded_logo.len() {
                                padded_logo[i].clone()
                            } else {
                                line.chars().take(54).collect::<String>().trim_end().to_string()
                            };
                            
                            if i < padded_logo.len() { // Logo
                                let col = logo_colors.get(i).cloned().unwrap_or(GREY);
                                row_spans.push(Span::styled(content_text.clone(), Style::default().fg(col).add_modifier(Modifier::BOLD)));
                                current_row_visual_w = console::measure_text_width(&content_text);
                            } else if i >= padded_logo.len() && i < footer_line { // Metadata
                                let text = content_text.trim(); // Trim all to align flush
                                current_row_visual_w = 0;

                                if text.starts_with("Welcome Back,") {
                                    row_spans.push(Span::styled("Welcome Back, ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)));
                                    let user_part = text.chars().skip(13).collect::<String>();
                                    let user_with_bang = format!("{}!", user_part.trim());
                                    current_row_visual_w += 14 + console::measure_text_width(&user_with_bang);
                                    row_spans.push(Span::styled(user_with_bang, Style::default().fg(CYAN).add_modifier(Modifier::BOLD)));
                                } else if text.starts_with("Model") || text.starts_with("Mode") || text.starts_with("Session") {
                                    let parts: Vec<&str> = text.splitn(2, ' ').collect();
                                    row_spans.push(Span::styled(format!("{:<8}", parts[0]), Style::default().fg(GREY)));
                                    current_row_visual_w += 8;
                                    if parts.len() > 1 {
                                        let val = parts[1].trim();
                                        current_row_visual_w += console::measure_text_width(val);
                                        row_spans.push(Span::styled(val.to_string(), Style::default().fg(GREY).add_modifier(Modifier::BOLD)));
                                    }
                                } else {
                                    current_row_visual_w += console::measure_text_width(text);
                                    row_spans.push(Span::styled(text.to_string(), Style::default().fg(GREY)));
                                }
                            } else {
                                current_row_visual_w = console::measure_text_width(&content_text);
                                row_spans.push(Span::raw(content_text));
                            }
                        }

                        // 4. Uniform Padding: Calculate and append exactly enough spaces
                        let padding_needed = target_w.saturating_sub(current_row_visual_w);
                        row_spans.push(Span::raw(" ".repeat(padding_needed)));

                        // 5. Close the Box: Reset and append right border
                        row_spans.push(Span::styled(" │", Style::default().fg(CHAT_BORDER)));
                        lines.push(Line::from(row_spans));
                    }

                    // 6. Seal the Main Box First: Draw the bottom border exactly after metadata
                    lines.push(Line::from(vec![
                        Span::styled(format!("  └{}┘", "─".repeat(target_w + 2)), Style::default().fg(CHAT_BORDER))
                    ]));

                    // 7. External Render: Print the hook line after the box is physically closed
                    // Column Alignment: The '  ' followed by '└' aligns it with the '  └' of the box above.
                    if footer_line < banner_lines.len() {
                        lines.push(Line::from(vec![
                            Span::styled(format!("  {}", banner_lines[footer_line].trim()), Style::default().fg(GREY).add_modifier(Modifier::ITALIC))
                        ]));
                    }
                } else {
                    for line in msg.lines() {
                        let mut spans = Vec::new();
                        spans.push(Span::styled("* ", Style::default().fg(DIM)));
                        spans.push(Span::styled(line.to_string(), Style::default().fg(GREY)));
                        lines.push(Line::from(spans));
                    }
                }
            }

            // ExecBlock::RawText(text) => {
            //     lines.push(Line::default());
            //     for line in text.lines() {
            //         lines.push(Line::from(Span::styled(
            //             line.to_string(),
            //             Style::default().fg(FG),
            //         )));
            //     }
            // }
        }
    }

    lines
}

fn render_content(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
    let scroll_indicator = if state.scroll > 0 {
        format!(" ↑ {}  ctrl+l → bottom ", state.scroll)
    } else {
        String::new()
    };

    let title_line = if scroll_indicator.is_empty() {
        Line::from(vec![
            Span::styled(" albert ", Style::default().fg(CHAT_BORDER).add_modifier(Modifier::BOLD))
        ])
    } else {
        Line::from(vec![
            Span::styled(" albert ", Style::default().fg(CHAT_BORDER).add_modifier(Modifier::BOLD)),
            Span::styled(scroll_indicator, Style::default().fg(GREY)),
        ])
    };

    let block = Block::default()
        .title(title_line)
        .borders(Borders::ALL)
        .border_style(Style::default().fg(CHAT_BORDER))
        .style(Style::default().bg(BG));

    let inner = block.inner(area);
    let lines = build_exec_lines(state, inner.width);
    let w = inner.width.max(1) as usize;

    // Compute total rendered height in rows, accounting for text wrapping.
    // Using usize to avoid u16 overflow with large logs.
    let total_wrapped: usize = lines
        .iter()
        .map(|line| {
            let chars: usize = line.spans.iter().map(|s| s.content.chars().count()).sum();
            if chars == 0 { 1 } else { (chars + w - 1) / w }
        })
        .sum();

    let visible = inner.height as usize;
    let total_wrapped_count = total_wrapped;
    // max_scroll is how many rows we can scroll up from the bottom
    let max_scroll = total_wrapped_count.saturating_sub(visible);
    
    // state.scroll is rows scrolled up from the bottom.
    // paragraph.scroll(y, x) is rows scrolled down from the TOP.
    // So scroll_y = max_scroll - state.scroll
    let scroll_row = max_scroll.saturating_sub(state.scroll as usize).min(u16::MAX as usize) as u16;

    let para = Paragraph::new(Text::from(lines))
        .style(Style::default().bg(BG).fg(FG))
        .wrap(Wrap { trim: false })
        .scroll((scroll_row, 0))
        .block(block);
    f.render_widget(para, area);
}

fn render_popup(f: &mut ratatui::Frame, area: Rect, items: &[PopupItem], selected: usize) {
    let total = items.len();
    let win_size = total.min(POPUP_WINDOW);

    // Center the visible window around the selected item
    let win_start = selected
        .saturating_sub(win_size / 2)
        .min(total.saturating_sub(win_size));
    let win_end = (win_start + win_size).min(total);

    let selectable_total = items.iter().filter(|i| !i.is_header).count();
    let selectable_idx = items[..selected.min(total.saturating_sub(1))]
        .iter()
        .filter(|i| !i.is_header)
        .count();

    // Is the currently selected item a drill-down parent?
    let sel_item = items.get(selected.min(total.saturating_sub(1)));
    let sel_is_drilldown = sel_item
        .map(|it| !it.is_header && is_drilldown(&it.complete))
        .unwrap_or(false);

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

    for (abs_i, item) in items[win_start..win_end].iter().enumerate() {
        let i = win_start + abs_i;
        if item.is_header {
            let label = format!("  {} ", item.display);
            lines.push(Line::from(Span::styled(label, Style::default().fg(CATEGORY_FG).bg(POPUP_BG))));
        } else {
            let is_sel = i == selected;
            let bg = if is_sel { POPUP_SEL_BG } else { POPUP_BG };
            let name_col = if is_sel { GREEN } else { POPUP_MATCH };
            let desc_col = if is_sel { FG } else { GREY };
            // Drill-down items get a "›" right-hand indicator
            let drilldown_hint = if is_sel && is_drilldown(&item.complete) {
                Span::styled("  ›", Style::default().fg(CYAN).bg(bg).add_modifier(Modifier::BOLD))
            } else {
                Span::styled("", Style::default().bg(bg))
            };
            lines.push(Line::from(vec![
                Span::styled("  ", Style::default().bg(bg)),
                Span::styled(
                    format!("/{}", item.display),
                    Style::default().fg(name_col).bg(bg).add_modifier(Modifier::BOLD),
                ),
                drilldown_hint,
                Span::styled("  ", Style::default().bg(bg)),
                Span::styled(item.desc.clone(), Style::default().fg(desc_col).bg(bg)),
            ]));
        }
    }

    // Nav footer — contextual based on selected item type
    let action_hint = if sel_is_drilldown { "enter → open  ·  " } else { "enter → select  ·  " };
    let nav = if selectable_total > 0 {
        format!(
            "  ({}/{})  ↑↓ navigate  ·  {action_hint}tab → complete  ·  esc dismiss",
            selectable_idx + 1,
            selectable_total,
        )
    } else {
        "  ↑↓ navigate  ·  esc dismiss".to_string()
    };
    lines.push(Line::from(Span::styled(nav, Style::default().fg(DIM).bg(POPUP_BG))));

    let para = Paragraph::new(Text::from(lines)).style(Style::default().bg(POPUP_BG));
    f.render_widget(para, area);
}

/// Full-screen help overlay — floats over the whole terminal, closed with Esc.
fn render_help_overlay(f: &mut ratatui::Frame, area: Rect, scroll: u16) {
    use ratatui::widgets::Clear;

    // Semi-transparent frame: clear the background first, then draw the box
    let overlay = Rect {
        x: area.x + 2,
        y: area.y + 1,
        width: area.width.saturating_sub(4),
        height: area.height.saturating_sub(2),
    };
    f.render_widget(Clear, overlay);

    const SECTION: Color = Color::Rgb(0, 200, 120);
    const CMD_C:   Color = Color::Rgb(0, 200, 255);
    const HINT_C:  Color = Color::Rgb(120, 120, 120);

    let mut lines: Vec<Line<'static>> = Vec::new();
    let h = |s: &'static str| Line::from(Span::styled(s, Style::default().fg(SECTION).add_modifier(Modifier::BOLD)));
    let c = |cmd: &'static str, desc: &'static str| Line::from(vec![
        Span::styled(format!("  {cmd:<22}"), Style::default().fg(CMD_C).add_modifier(Modifier::BOLD)),
        Span::styled(desc, Style::default().fg(FG)),
    ]);
    let hint_line = |s: &'static str| Line::from(Span::styled(format!("  {s}"), Style::default().fg(HINT_C)));
    let blank = || Line::from("");

    lines.push(blank());
    lines.push(h("  MODELS & PROVIDERS"));
    lines.push(c("/model <id>",          "switch model — opens picker when blank"));
    lines.push(c("/auth <provider>",      "set API key for a provider"));
    lines.push(c("/auth browser",         "OAuth browser login (Google / GitHub)"));
    lines.push(hint_line("Providers: anthropic · openai · google · xai · deepseek · mistral · groq · cerebras"));
    lines.push(hint_line("           together · openrouter · perplexity · cohere · cerebras · qwen"));
    lines.push(hint_line("           openrouter · cohere · perplexity · together · fireworks · novita · deepinfra"));
    lines.push(hint_line("           sambanova · nvidia · zhipu · qwen · moonshot · chutes · huggingface"));
    lines.push(hint_line("           github · azure · ollama · lmstudio · openai-compat"));
    lines.push(blank());

    lines.push(h("  TIPS & INTERACTION"));
    lines.push(hint_line("Selection: Click & Drag to select and copy text."));
    lines.push(hint_line("Scrolling: Mouse wheel or Shift+Up/Down arrows to scroll chat history."));
    lines.push(hint_line("Override:  Hold SHIFT to force native terminal selection/scrolling."));
    lines.push(blank());
    lines.push(h("  SESSION"));
    lines.push(c("/compact",              "summarise old context to free tokens"));
    lines.push(c("/compress",             "aggressive compression — strip tool outputs"));
    lines.push(c("/status",              "show token usage and session info"));
    lines.push(c("/cost",                "show estimated API cost for this session"));
    lines.push(c("/clear",               "wipe conversation history"));
    lines.push(c("/export",              "save conversation to markdown file"));
    lines.push(c("/session",             "list saved sessions"));
    lines.push(c("/resume <id>",          "restore a previous session"));
    lines.push(blank());
    lines.push(h("  AGENT MODES"));
    lines.push(c("/plan <task>",          "decompose task into numbered steps"));
    lines.push(c("/loop <mission>",       "autonomous mode — runs until MISSION COMPLETE"));
    lines.push(c("/tdd <spec>",           "test-driven development loop"));
    lines.push(c("/code-review",          "review staged diff"));
    lines.push(c("/bughunter",            "scan codebase for bugs"));
    lines.push(c("/refactor",             "refactor current file"));
    lines.push(blank());
    lines.push(h("  WORKSPACE & GIT"));
    lines.push(c("/commit",              "commit staged changes with AI message"));
    lines.push(c("/pr",                  "create pull request"));
    lines.push(c("/diff",                "show current git diff"));
    lines.push(c("/init",                "scaffold ALBERT.md in current directory"));
    lines.push(c("/memory",              "view/edit persistent memory"));
    lines.push(blank());
    lines.push(h("  PERMISSIONS"));
    lines.push(c("/permissions",          "show current permission mode — picker when blank"));
    lines.push(hint_line("Modes: read-only · workspace-write · danger-full-access"));
    lines.push(blank());
    lines.push(h("  KEYBOARD"));
    lines.push(hint_line("Enter       send message"));
    lines.push(hint_line("Tab         autocomplete command from popup"));
    lines.push(hint_line("↑ ↓         navigate input history"));
    lines.push(hint_line("PageUp/Dn   scroll conversation (or Shift + ↑/↓)"));
    lines.push(hint_line("MouseWheel  scroll conversation"));
    lines.push(hint_line("Shift+Click select text / right-click (native)"));
    lines.push(hint_line("Esc         interrupt · dismiss popup · close this overlay"));
    lines.push(hint_line("Ctrl+Space  toggle voice recording (whisper STT)"));
    lines.push(hint_line("Ctrl+V      paste from clipboard"));
    lines.push(hint_line("Ctrl+C      quit"));
    lines.push(blank());
    lines.push(Line::from(Span::styled("  Esc to close", Style::default().fg(DIM))));

    let total = lines.len() as u16;
    let visible = overlay.height.saturating_sub(2);
    let max_scroll = total.saturating_sub(visible);
    let scroll = scroll.min(max_scroll);

    let block = Block::default()
        .title(" Albert — Command Reference ")
        .title_style(Style::default().fg(GREEN).add_modifier(Modifier::BOLD))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(Color::Rgb(50, 50, 50)))
        .style(Style::default().bg(Color::Rgb(8, 8, 8)));

    let para = Paragraph::new(Text::from(lines))
        .block(block)
        .scroll((scroll, 0));
    f.render_widget(para, overlay);
}

/// Multi-row expanding input bar wrapped in a turquoise border.
/// Text wraps automatically; the real terminal cursor is placed via set_cursor_position.
fn render_input(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
    // Outer turquoise border — rendered on the full area.
    let border_col = match &state.auth_flow {
        Some(AuthFlowPhase::Key { .. })   => ORANGE,
        Some(AuthFlowPhase::Model { .. }) => GREEN,
        None => if state.image_path_overlay {
            Color::Rgb(0, 180, 180)
        } else {
            INPUT_BORDER
        },
    };
    let input_block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(border_col))
        .style(Style::default().bg(USER_BOX_BG));
    f.render_widget(input_block.clone(), area);

    // Inner area = area minus the 1-row border on each side.
    let inner = input_block.inner(area);

    let branch = git_branch_cached();
    let badge_text = branch.as_deref().map(|b| format!(" {b} ")).unwrap_or_default();
    let badge_w = badge_text.chars().count() as u16;

    let h_layout = if badge_w > 0 && inner.width > badge_w + 4 {
        Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Min(4), Constraint::Length(badge_w)])
            .split(inner)
    } else {
        Layout::default()
            .direction(Direction::Horizontal)
            .constraints([Constraint::Min(1)])
            .split(inner)
    };

    let text_area = h_layout[0];

    // Render input text with wrapping (or dim placeholder when empty).
    // In auth_flow mode: show provider prompt and mask the typed key.
    let para = if let Some(n) = state.paste_line_count {
        Paragraph::new(Line::from(vec![
            Span::styled(" ≻ ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
            Span::styled(
                format!("[Pasted Text: {} lines]", n),
                Style::default()
                    .fg(CYAN)
                    .bg(USER_BOX_BG),
            ),
            Span::styled("  Continuing...", Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
        ]))
    } else if let Some(ref phase) = state.auth_flow {
        match phase {
            AuthFlowPhase::Key { provider } => {
                if state.input.is_empty() {
                    Paragraph::new(Line::from(vec![
                        Span::styled(" 🔑 ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
                        Span::styled(
                            format!("API key for {provider}:"),
                            Style::default().fg(ORANGE),
                        ),
                        Span::styled("  (press Enter to save)", Style::default().fg(DIM)),
                    ]))
                } else {
                    let masked: String = "*".repeat(state.input.chars().count());
                    Paragraph::new(Line::from(vec![
                        Span::styled(" 🔑 ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
                        Span::styled(masked, Style::default().fg(ORANGE)),
                        Span::styled(
                            format!("  [{} chars]", state.input.chars().count()),
                            Style::default().fg(DIM),
                        ),
                    ]))
                }
            }
            AuthFlowPhase::Model { provider, models } => {
                let prompt = format!("model for {provider}  ·  type 1–{} or model id:", models.len());
                if state.input.is_empty() {
                    Paragraph::new(Line::from(vec![
                        Span::styled(" ⚡ ", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
                        Span::styled(prompt, Style::default().fg(GREEN)),
                        Span::styled("  (Enter to confirm)", Style::default().fg(DIM)),
                    ]))
                } else {
                    Paragraph::new(Line::from(vec![
                        Span::styled(" ⚡ ", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
                        Span::styled(state.input.clone(), Style::default().fg(FG)),
                    ]))
                }
            }
        }
    } else if state.image_path_overlay {
        if state.input.is_empty() {
            Paragraph::new(Line::from(vec![
                Span::styled(" 📎 ", Style::default().fg(Color::Rgb(0, 200, 180)).add_modifier(Modifier::BOLD)),
                Span::styled("Enter image path:", Style::default().fg(Color::Rgb(0, 200, 180))),
                Span::styled("  (Esc to cancel)", Style::default().fg(DIM)),
            ]))
        } else {
            Paragraph::new(Line::from(vec![
                Span::styled(" 📎 ", Style::default().fg(Color::Rgb(0, 200, 180)).add_modifier(Modifier::BOLD)),
                Span::styled(state.input.clone(), Style::default().fg(FG)),
            ]))
        }
    } else if state.input.is_empty() {
        let img_badge = if state.pending_images.is_empty() {
            String::new()
        } else {
            format!("  [📎 {}]", state.pending_images.len())
        };
        Paragraph::new(Line::from(vec![
            Span::styled(" ≻ ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
            Span::styled("Type your message or @path/to/file", Style::default().fg(DIM)),
            Span::styled(img_badge, Style::default().fg(Color::Rgb(0, 200, 180))),
        ]))
    } else {
        let (prompt_txt, prompt_col) = if state.history_idx.is_some() {
            (" ↑ ", ORANGE)
        } else {
            (" ≻ ", CYAN)
        };

        let w = text_area.width as usize;
        let p_len = 3;
        let chars: Vec<char> = state.input.chars().collect();
        let mut lines = Vec::new();

        if chars.len() <= w.saturating_sub(p_len) {
            let mut spans = vec![
                Span::styled(prompt_txt, Style::default().fg(prompt_col).add_modifier(Modifier::BOLD)),
                Span::styled(state.input.clone(), Style::default().fg(FG)),
            ];
            if !state.pending_images.is_empty() {
                spans.push(Span::styled(
                    format!("  [📎 {}]", state.pending_images.len()),
                    Style::default().fg(Color::Rgb(0, 200, 180)),
                ));
            }
            lines.push(Line::from(spans));
        } else {
            // First line with prompt
            lines.push(Line::from(vec![
                Span::styled(prompt_txt, Style::default().fg(prompt_col).add_modifier(Modifier::BOLD)),
                Span::styled(chars[0..w.saturating_sub(p_len)].iter().collect::<String>(), Style::default().fg(FG)),
            ]));
            // Subsequent lines strictly wrapped
            let mut start = w.saturating_sub(p_len);
            while start < chars.len() {
                let end = (start + w).min(chars.len());
                lines.push(Line::from(vec![
                    Span::styled(chars[start..end].iter().collect::<String>(), Style::default().fg(FG)),
                ]));
                start = end;
            }
        }
        Paragraph::new(lines)
    };
    f.render_widget(para.style(Style::default().bg(USER_BOX_BG)), text_area);

    // Place the real blinking terminal cursor inside the inner text area.
    {
        const PREFIX: u16 = 3; 
        let (cx, cy) = if state.paste_line_count.is_some() {
            (text_area.x + PREFIX + 1, text_area.y)
        } else {
            let w = text_area.width as usize;
            let p = PREFIX as usize;
            let (visual_row, visual_col) = if state.cursor < w.saturating_sub(p) {
                (0, state.cursor + p)
            } else {
                let rem = state.cursor - (w.saturating_sub(p));
                (1 + rem / w, rem % w)
            };
            let cx = (text_area.x + visual_col as u16).min(text_area.x + text_area.width.saturating_sub(1));
            let cy = (text_area.y + visual_row as u16).min(text_area.y + text_area.height.saturating_sub(1));
            (cx, cy)
        };
        f.set_cursor_position((cx, cy));
    }

    // Branch badge (inside the border, right side)
    if h_layout.len() == 2 {
        f.render_widget(
            Paragraph::new(Line::from(Span::styled(
                badge_text,
                Style::default().fg(GREEN).bg(BRANCH_BG).add_modifier(Modifier::BOLD),
            )))
            .style(Style::default().bg(BRANCH_BG)),
            h_layout[1],
        );
    }
}

pub fn render_report_card(f: &mut ratatui::Frame, state: &TuiState) {
    let area = f.area();
    let w = 76;
    let models_extra = if state.models_used.is_empty() { 0u16 } else { state.models_used.len() as u16 + 2 };
    let h = 20 + models_extra;
    let x = (area.width.saturating_sub(w)) / 2;
    let y = (area.height.saturating_sub(h)) / 2;
    let popup_area = Rect::new(x, y, w.min(area.width), h.min(area.height));

    let mut lines = Vec::new();
    lines.push(Line::from(vec![
        Span::styled(" 𒀭 Agent powering down. Goodbye!", Style::default().fg(GREEN).add_modifier(Modifier::BOLD)),
    ]));
    lines.push(Line::default());

    let label_style = Style::default().fg(GREY);
    let value_style = Style::default().fg(CYAN).add_modifier(Modifier::BOLD);

    lines.push(Line::from(Span::styled("  Interaction Summary", Style::default().add_modifier(Modifier::BOLD))));
    lines.push(Line::from(vec![
        Span::styled("  Session ID:                 ", label_style),
        Span::styled(&state.session_id, value_style),
    ]));

    let success_rate = if state.tool_calls > 0 {
        (state.tool_success as f32 / state.tool_calls as f32) * 100.0
    } else {
        0.0
    };

    lines.push(Line::from(vec![
        Span::styled("  Tool Calls:                 ", label_style),
        Span::styled(format!("{} ( ✓ {}  ✗ {} )", state.tool_calls, state.tool_success, state.tool_failure), value_style),
    ]));
    lines.push(Line::from(vec![
        Span::styled("  Success Rate:               ", label_style),
        Span::styled(format!("{:.1}%", success_rate), value_style),
    ]));
    lines.push(Line::default());

    lines.push(Line::from(Span::styled("  Resources", Style::default().add_modifier(Modifier::BOLD))));
    lines.push(Line::from(vec![
        Span::styled("  Total Tokens:               ", label_style),
        Span::styled(format!("{} in  ·  {} out", fmt_tokens(state.tokens_in), fmt_tokens(state.tokens_out)), value_style),
    ]));
    lines.push(Line::default());

    lines.push(Line::from(Span::styled("  Performance", Style::default().add_modifier(Modifier::BOLD))));
    let wall_secs = state.session_start.elapsed().as_secs();
    let wall_time = if wall_secs >= 60 { format!("{}m {}s", wall_secs / 60, wall_secs % 60) } else { format!("{wall_secs}s") };
    
    let active_secs = state.agent_active_ms / 1000;
    let active_time = if active_secs >= 60 { format!("{}m {}s", active_secs / 60, active_secs % 60) } else { format!("{active_secs}s") };
    
    lines.push(Line::from(vec![
        Span::styled("  Wall Time:                  ", label_style),
        Span::styled(wall_time, value_style),
    ]));
    lines.push(Line::from(vec![
        Span::styled("  Agent Active:               ", label_style),
        Span::styled(active_time, value_style),
    ]));

    let api_pct = if state.agent_active_ms > 0 { (state.api_time_ms as f32 / state.agent_active_ms as f32) * 100.0 } else { 0.0 };
    let tool_pct = if state.agent_active_ms > 0 { (state.tool_time_ms as f32 / state.agent_active_ms as f32) * 100.0 } else { 0.0 };

    lines.push(Line::from(vec![
        Span::styled("    » API Time:               ", label_style),
        Span::styled(format!("{}s ({:.1}%)", state.api_time_ms / 1000, api_pct), value_style),
    ]));
    lines.push(Line::from(vec![
        Span::styled("    » Tool Time:              ", label_style),
        Span::styled(format!("{}s ({:.1}%)", state.tool_time_ms / 1000, tool_pct), value_style),
    ]));
    
    lines.push(Line::default());

    if !state.models_used.is_empty() {
        lines.push(Line::from(Span::styled("  Models Used", Style::default().add_modifier(Modifier::BOLD))));
        for model in &state.models_used {
            lines.push(Line::from(vec![
                Span::styled("    ", label_style),
                Span::styled(model.as_str(), value_style),
            ]));
        }
        lines.push(Line::default());
    }

    lines.push(Line::from(vec![
        Span::styled("  To resume this session: ", label_style),
        Span::styled(format!("albert --resume {}", state.session_id), Style::default().fg(GREEN)),
    ]));
    lines.push(Line::default());
    lines.push(Line::from(vec![
        Span::styled("  ( Press any key to exit )", Style::default().fg(DIM).add_modifier(Modifier::ITALIC)),
    ]));

    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(CHAT_BORDER))
        .style(Style::default().bg(BG));

    let para = Paragraph::new(lines)
        .block(block);

    f.render_widget(para, popup_area);
}

/// Derive a human-readable activity label from the currently running tool (if any).
fn current_activity(state: &TuiState) -> String {
    let elapsed = state.session_start.elapsed().as_secs_f32();
    let tick = (elapsed * 10.0) as usize; // 10Hz base tick
    let phrase_idx = (tick / 30) % 10;   // Rotate phrase every 3 seconds (30 ticks)

    let thinking_phrases = [
        "Computing causal vectors...",
        "Navigating ternary matrices...",
        "Weighing ontological states...",
        "Resolving logic branches...",
        "Distilling intent...",
        "Evaluating outcome probabilities...",
        "Synthesizing cognitive shards...",
        "Mapping architecture dependencies...",
        "Optimizing heuristic paths...",
        "Synchronizing neural weights...",
    ];

    let reading_phrases = [
        "Ingesting context...",
        "Parsing structural data...",
        "Scanning workspace geometry...",
        "Resolving symbol references...",
        "Analyzing byte streams...",
        "Absorbing local state...",
        "Decoding manifest layers...",
        "Tracing source origins...",
        "Indexing project memory...",
        "Querying filesystem truth...",
    ];

    let writing_phrases = [
        "Compiling output...",
        "Forging response...",
        "Committing logic to buffer...",
        "Assembling content blocks...",
        "Refining prose...",
        "Emitting signal...",
        "Hardening implementation...",
        "Polishing syntax...",
        "Projecting thought into text...",
        "Finalizing assistant state...",
    ];

    for block in state.exec_log.iter().rev() {
        if let ExecBlock::ToolUse { name, active, .. } = block {
            if *active {
                let n = name.as_str();
                if n.contains("read") {
                    return reading_phrases[phrase_idx].to_string();
                } else if n.contains("write") || n.contains("edit") {
                    return writing_phrases[phrase_idx].to_string();
                } else if n.contains("bash") || n.contains("execute") {
                    return format!("Running {}...", n);
                } else if n.contains("grep") || n.contains("search") {
                    return format!("Searching {}...", n);
                } else if n.contains("glob") || n.contains("scan") {
                    return format!("Scanning {}...", n);
                } else if n.contains("web") || n.contains("fetch") {
                    return "Fetching...".to_string();
                } else if n.contains("plan") {
                    return "Planning...".to_string();
                } else {
                    return "On it...".to_string();
                };
            }
        }
    }
    
    thinking_phrases[phrase_idx].to_string()
}

/// 1-row status strip — ALWAYS visible.
/// Recording: `@ Recording…  ctrl+space to stop`
/// Working:   `* Reading… (2s · ↓ 42 tokens)`
/// Idle:      `◆ Idle  ·  type / for commands`
fn render_status(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
    let line = if !state.trusted {
        Line::from(vec![
            Span::styled(" ⚠ ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
            Span::styled("Untrusted Folder", Style::default().fg(ORANGE)),
            Span::styled("  tools will require manual approval", Style::default().fg(DIM)),
        ])
    } else if state.voice_transcribing {
        Line::from(vec![
            Span::styled(" 𒀭 ", Style::default().fg(Color::Rgb(0, 200, 140)).add_modifier(Modifier::BOLD)),
            Span::styled("Transcribing…", Style::default().fg(Color::Rgb(0, 200, 140))),
            Span::styled("  converting speech to text", Style::default().fg(GREY)),
        ])
    } else if state.is_recording {
        let elapsed = state.session_start.elapsed().as_secs_f64();
        let blink = (elapsed * 2.5).sin() > 0.0;
        let mic_color = if blink { Color::Rgb(255, 60, 60) } else { Color::Rgb(200, 30, 30) };
        Line::from(vec![
            Span::styled(" 𒀭 ", Style::default().fg(mic_color).add_modifier(Modifier::BOLD)),
            Span::styled("Recording…", Style::default().fg(mic_color)),
            Span::styled("  ctrl+space to stop & transcribe", Style::default().fg(GREY)),
        ])
    } else if state.is_prompting.load(Ordering::Relaxed) {
        Line::from(vec![
            Span::styled(" 𒀭 ", Style::default().fg(ORANGE).add_modifier(Modifier::BOLD)),
            Span::styled("Waiting for you…", Style::default().fg(ORANGE)),
            Span::styled("  check main terminal for approval prompt", Style::default().fg(GREY)),
        ])
    } else if state.working {
        let elapsed_ms = state.turn_start.map(|t| t.elapsed().as_millis()).unwrap_or(0);
        let secs = elapsed_ms / 1000;
        let timer = if secs >= 60 {
            format!("{}m {}s", secs / 60, secs % 60)
        } else {
            format!("{secs}s")
        };
        let tok_str = if state.tokens_out > 0 {
            format!(" · ↓ {} tokens", fmt_tokens(state.tokens_out))
        } else {
            String::new()
        };
        let activity = current_activity(state);

        // Pulse the Dingir symbol when active, shimmer the activity text
        let elapsed = state.session_start.elapsed().as_secs_f32();
        let pulse_style = get_pulse_style(elapsed, true);
        let shimmer_spans = get_shimmer_spans(&activity, elapsed);

        let mut spans = vec![Span::styled(" 𒀭 ", pulse_style)];
        spans.extend(shimmer_spans);
        spans.push(Span::styled(format!(" ({timer}{tok_str})"), Style::default().fg(GREY)));

        Line::from(spans)
    } else {
        let last_worked = state.exec_log.iter().rev().find_map(|b| {
            if let ExecBlock::WorkedFor(s) = b { Some(*s) } else { None }
        });
        let worked_part = last_worked.map(|secs| {
            let dur = if secs >= 60 {
                format!("{}m {}s", secs / 60, secs % 60)
            } else {
                format!("{secs}s")
            };
            format!("  ·  Worked for {dur}  ")
        }).unwrap_or_else(|| "  ·  ".to_string());

        Line::from(vec![
            Span::styled(" 𒀭 ", Style::default().fg(DIM).add_modifier(Modifier::BOLD)),
            Span::styled("Idle", Style::default().fg(DIM)),
            Span::styled(worked_part, Style::default().fg(DIM)),
            Span::styled("type / for commands", Style::default().fg(DIM)),
        ])
    };
    f.render_widget(Paragraph::new(line).style(Style::default().bg(STATUS_BG)), area);
}

/// 1-row rotating tip strip — sits between the input and footer.
fn render_tips(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
    let secs = state.session_start.elapsed().as_secs();
    let tip_idx = (secs / 8) as usize % TIPS.len();
    let tip = TIPS[tip_idx];
    let line = Line::from(vec![
        Span::styled("⎿  ", Style::default().fg(DIM)),
        Span::styled("Tip: ", Style::default().fg(DIM)),
        Span::styled(tip, Style::default().fg(GREY)),
    ]);
    f.render_widget(Paragraph::new(line).style(Style::default().bg(BG)), area);
}

/// 1-row footer.
/// When working : `▶▶  esc to interrupt  ·  ctrl+c to quit`
/// When idle    : `▶▶  model  ·  dir  ·  perm  ·  tokens↑ tokens↓`
fn render_footer(f: &mut ratatui::Frame, area: Rect, state: &TuiState) {
    let line = if state.working {
        Line::from(vec![
            Span::styled(" ▶▶ ", Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
            Span::styled("esc to interrupt", Style::default().fg(CYAN)),
            Span::styled(
                "  ·  ctrl+c to quit",
                Style::default().fg(Color::Rgb(60, 60, 60)),
            ),
        ])
    } else {
        let dir = std::path::Path::new(&state.cwd)
            .file_name()
            .and_then(|n| n.to_str())
            .unwrap_or(&state.cwd);
        let perm = if state.permission_mode.is_empty() {
            String::new()
        } else {
            format!("  ·  {}", state.permission_mode)
        };
        let base = format!(" {}  ·  {}{}", state.model, dir, perm);
        // Very subtle colors for the sidenote footer
        let base_style = Style::default().fg(Color::Rgb(50, 50, 50));
        let tok_style = Style::default().fg(Color::Rgb(40, 40, 40));

        if state.tokens_in > 0 {
            let tok_str = format!(
                "  ·  {}↑ {}↓",
                fmt_tokens(state.tokens_in),
                fmt_tokens(state.tokens_out),
            );
            Line::from(vec![
                Span::styled(base, base_style),
                Span::styled(tok_str, tok_style),
            ])
        } else {
            Line::from(Span::styled(base, base_style))
        }
    };
    f.render_widget(
        Paragraph::new(line).style(Style::default().bg(BG)),
        area,
    );
}

fn fmt_tokens(n: u32) -> 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()
    }
}

/// Read current git branch without blocking (uses std::process, fire-and-forget cache).
fn git_branch_cached() -> Option<String> {
    use std::sync::OnceLock;
    use std::time::SystemTime;

    static CACHE: OnceLock<std::sync::Mutex<(Option<String>, SystemTime)>> = OnceLock::new();
    let cache = CACHE.get_or_init(|| std::sync::Mutex::new((None, SystemTime::UNIX_EPOCH)));

    let mut guard = cache.lock().ok()?;
    let (ref mut branch, ref mut updated) = *guard;

    let age = SystemTime::now().duration_since(*updated).unwrap_or_default();
    if age.as_secs() > 10 {
        // refresh in background; show stale value in the meantime
        if let Ok(out) = std::process::Command::new("git")
            .args(["rev-parse", "--abbrev-ref", "HEAD"])
            .output()
        {
            let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
            if !s.is_empty() && s != "HEAD" {
                *branch = Some(s);
            }
        }
        *updated = SystemTime::now();
    }
    branch.clone()
}

// ── Voice transcription ────────────────────────────────────────────────────────

/// Transcription priority:
///   1. local `whisper` CLI  (openai-whisper or whisper.cpp — free, offline)
///   2. OpenAI Whisper API   (requires OPENAI_API_KEY)
///   3. friendly error with install hint
async fn transcribe(wav_path: &str) -> Result<String, String> {
    // ── 1. local whisper CLI ──────────────────────────────────────────────────
    if let Ok(out) = std::process::Command::new("whisper")
        .args([wav_path, "--model", "tiny", "--language", "en",
               "--output_format", "txt", "--output_dir", "/tmp", "--fp16", "False"])
        .output()
    {
        if out.status.success() {
            // whisper writes <filename>.txt next to the input or in output_dir
            let txt_path = "/tmp/albert-voice.txt";
            if let Ok(text) = std::fs::read_to_string(txt_path) {
                let _ = std::fs::remove_file(txt_path);
                let t = text.trim().to_string();
                if !t.is_empty() { return Ok(t); }
            }
            // also try stdout directly
            let stdout = String::from_utf8_lossy(&out.stdout);
            let t = stdout.trim().to_string();
            if !t.is_empty() { return Ok(t); }
        }
    }

    // ── 2. OpenAI Whisper API ─────────────────────────────────────────────────
    if let Ok(key) = std::env::var("OPENAI_API_KEY") {
        if !key.is_empty() {
            let wav = std::fs::read(wav_path).map_err(|e| e.to_string())?;
            return transcribe_openai(wav, &key).await;
        }
    }

    // ── 3. no STT available ───────────────────────────────────────────────────
    Err("voice: no STT available — install whisper:  pip install openai-whisper".to_string())
}

/// POST a WAV buffer to the OpenAI Whisper API.
async fn transcribe_openai(wav: Vec<u8>, api_key: &str) -> Result<String, String> {
    let client = reqwest::Client::new();
    let part = reqwest::multipart::Part::bytes(wav)
        .file_name("audio.wav")
        .mime_str("audio/wav")
        .map_err(|e| e.to_string())?;
    let form = reqwest::multipart::Form::new()
        .part("file", part)
        .text("model", "whisper-1");
    let resp = client
        .post("https://api.openai.com/v1/audio/transcriptions")
        .header("Authorization", format!("Bearer {api_key}"))
        .multipart(form)
        .send()
        .await
        .map_err(|e| e.to_string())?;
    let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
    json.get("text")
        .and_then(|v| v.as_str())
        .map(|s| s.to_string())
        .ok_or_else(|| format!("unexpected response: {json}"))
}

// ── TuiApp ────────────────────────────────────────────────────────────────────

pub struct TuiApp {
    pub state: Arc<Mutex<TuiState>>,
    pub event_tx: tokio::sync::mpsc::UnboundedSender<TuiEvent>,
    event_rx: tokio::sync::mpsc::UnboundedReceiver<TuiEvent>,
    submit_tx: std::sync::mpsc::Sender<String>,
    key_paused: Arc<AtomicBool>,
    /// Set by ESC during a running turn — main thread exits the event loop.
    pub cancel_flag: Arc<AtomicBool>,
    /// Live arecord process while voice recording is active.
    voice_process: Arc<std::sync::Mutex<Option<std::process::Child>>>,
}

impl TuiApp {
    pub fn new(model: String, cwd: String, permission_mode: String, session_id: String) -> (Self, std::sync::mpsc::Receiver<String>) {
        let (event_tx, event_rx) = tokio::sync::mpsc::unbounded_channel();
        let (submit_tx, submit_rx) = std::sync::mpsc::channel();
        let app = Self {
            state: Arc::new(Mutex::new(TuiState::new(model, cwd, permission_mode, session_id))),
            event_tx,
            event_rx,
            submit_tx,
            key_paused: Arc::new(AtomicBool::new(false)),
            cancel_flag: Arc::new(AtomicBool::new(false)),
            voice_process: Arc::new(std::sync::Mutex::new(None)),
        };
        (app, submit_rx)
    }

    pub fn run(self) {
        if let Err(e) = self.run_inner() {
            eprintln!("tui: {e}");
        }
    }

    fn run_inner(mut self) -> Result<(), Box<dyn std::error::Error>> {
        enable_raw_mode()?;
        io::stdout().execute(EnterAlternateScreen)?;
        io::stdout().execute(EnableBracketedPaste)?;
        io::stdout().execute(EnableMouseCapture)?;

        let backend = CrosstermBackend::new(io::stdout());
        let mut terminal = Terminal::new(backend)?;
        terminal.clear()?;

        let cancel_flag = Arc::clone(&self.cancel_flag);

        // Key-event thread — paused during slash command Suspend
        let ktx = self.event_tx.clone();
        let key_paused = Arc::clone(&self.key_paused);
        std::thread::spawn(move || loop {
            if key_paused.load(Ordering::Relaxed) {
                std::thread::sleep(Duration::from_millis(30));
                continue;
            }
            if event::poll(Duration::from_millis(50)).unwrap_or(false) {
                match event::read() {
                    Ok(Event::Key(k)) => { let _ = ktx.send(TuiEvent::Key(k)); }
                    Ok(Event::Paste(text)) => { let _ = ktx.send(TuiEvent::PasteText(text)); }
                    Ok(Event::Resize(_, _)) => { let _ = ktx.send(TuiEvent::Tick); }
                    Ok(Event::Mouse(me)) => {
                        match me.kind {
                            MouseEventKind::ScrollUp => { let _ = ktx.send(TuiEvent::ScrollUp); }
                            MouseEventKind::ScrollDown => { let _ = ktx.send(TuiEvent::ScrollDown); }
                            _ => {}
                        }
                    }
                    _ => {}
                }
            }
        });

        // Tick thread: 100 ms redraws keep the working timer live
        let ttx = self.event_tx.clone();
        std::thread::spawn(move || loop {
            std::thread::sleep(Duration::from_millis(100));
            let _ = ttx.send(TuiEvent::Tick);
        });

        let rt = tokio::runtime::Builder::new_current_thread()
            .enable_all()
            .build()?;

        rt.block_on(async {
            // Rate-limit rendering to ~30fps (33ms between draws).
            // Without this, rapid streaming events cause 1000+ draws/sec which the
            // terminal coalesces into a single visible frame — giving "all at once" appearance.
            let mut last_draw = Instant::now();
            const DRAW_INTERVAL: Duration = Duration::from_millis(33);

            loop {
                // Draw if enough time has passed since the last frame.
                if last_draw.elapsed() >= DRAW_INTERVAL {
                    {
                        let state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        terminal.draw(|f| render(f, &state))?;
                    }
                    last_draw = Instant::now();
                }

                // Wait for the next event, but with a deadline so we always redraw
                // at ~30fps even when no events arrive (keeps spinner/timer live).
                let wait = DRAW_INTERVAL.saturating_sub(last_draw.elapsed());
                let ev = match tokio::time::timeout(wait, self.event_rx.recv()).await {
                    Ok(ev) => ev,
                    Err(_) => continue, // timeout — loop back to draw
                };
                match ev {
                    // ── keyboard ──────────────────────────────────────────────
                    Some(TuiEvent::Key(key)) => {
                        let mut quit_event: Option<TuiEvent> = None;
                        let mut submit_text: Option<String> = None;
                        // voice_toggle: true=start recording, false=stop recording, None=no change
                        let mut voice_toggle: Option<bool> = None;
                        {
                            let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                            let items = state_popup_items(&state);
                            let has_popup = !items.is_empty();

                            match (key.code, key.modifiers) {
                                // ── HITL (Tool Approval) ──────────────────────
                                (KeyCode::Up, _) if state.awaiting_tool_approval.is_some() => {
                                    state.hitl_selected = state.hitl_selected.saturating_sub(1);
                                }
                                (KeyCode::Down, _) if state.awaiting_tool_approval.is_some() => {
                                    state.hitl_selected = (state.hitl_selected + 1).min(3);
                                }
                                (KeyCode::Enter, _) if state.awaiting_tool_approval.is_some() => {
                                    let (approved, feedback) = match state.hitl_selected {
                                        0 => (true,  None),
                                        1 => (true,  Some("__session__".to_string())),
                                        2 => (false, Some("__changes__".to_string())),
                                        _ => (false, None),
                                    };
                                    let _ = self.event_tx.send(TuiEvent::ToolApprovalResponse { approved, feedback });
                                }
                                (KeyCode::Esc, _) if state.awaiting_tool_approval.is_some() => {
                                    let _ = self.event_tx.send(TuiEvent::ToolApprovalResponse {
                                        approved: false,
                                        feedback: None,
                                    });
                                }

                                (KeyCode::Char('c'), KeyModifiers::CONTROL)
                                | (KeyCode::Char('q'), KeyModifiers::CONTROL) => {
                                    if state.quit_confirm {
                                        quit_event = Some(TuiEvent::QuitWithReport);
                                    } else {
                                        state.quit_confirm = true;
                                        state.push_exec(ExecBlock::SystemMsg("Press Ctrl+C again to exit session".to_string()));
                                    }
                                }

                                // Ctrl+Space — toggle voice recording
                                (KeyCode::Char(' '), KeyModifiers::CONTROL) => {
                                    state.is_recording = !state.is_recording;
                                    voice_toggle = Some(state.is_recording);
                                }

                                // Ctrl+I — open image-attach overlay
                                (KeyCode::Char('i'), KeyModifiers::CONTROL) => {
                                    state.image_path_overlay = true;
                                    state.input.clear();
                                    state.cursor = 0;
                                    state.paste_line_count = None;
                                }

                                // Ctrl+O — toggle collapse on the last collapsible block
                                (KeyCode::Char('o'), KeyModifiers::CONTROL) => {
                                    if let Some(last_idx) = state.exec_log.len().checked_sub(1) {
                                        if state.collapsed_blocks.contains(&last_idx) {
                                            state.collapsed_blocks.remove(&last_idx);
                                        } else {
                                            state.collapsed_blocks.insert(last_idx);
                                        }
                                    }
                                }

                                // Ctrl+V — paste from system clipboard
                                (KeyCode::Char('v'), KeyModifiers::CONTROL) => {
                                    if let Ok(mut board) = arboard::Clipboard::new() {
                                        if let Ok(text) = board.get_text() {
                                            let line_count = text.lines().count();
                                            if line_count > 1 || text.chars().count() > 2000 {
                                                state.input = text;
                                                state.cursor = 0;
                                                state.paste_line_count = Some(line_count);
                                            } else {
                                                for ch in text.chars() {
                                                    if ch == '\n' || ch == '\r' {
                                                        state.input_insert(' ');
                                                    } else {
                                                        state.input_insert(ch);
                                                    }
                                                }
                                            }
                                        }
                                    }
                                }
                                // ESC: close help overlay → dismiss popup → clear paste → reset scroll
                                (KeyCode::Esc, _) => {
                                    if state.working {
                                        cancel_flag.store(true, Ordering::Relaxed);
                                    } else if state.image_path_overlay {
                                        state.image_path_overlay = false;
                                        state.input.clear();
                                        state.cursor = 0;
                                    } else if state.help_open {
                                        state.help_open = false;
                                        state.help_scroll = 0;
                                    } else if state.paste_line_count.is_some() {
                                        state.input.clear();
                                        state.cursor = 0;
                                        state.paste_line_count = None;
                                    } else if has_popup {
                                        state.input.clear();
                                        state.cursor = 0;
                                        state.popup_selected = 0;
                                    } else {
                                        state.scroll = 0;
                                    }
                                }
                                // PageUp/Down in help popup
                                (KeyCode::Up, _) | (KeyCode::PageUp, _) if state.help_open => {
                                    state.help_scroll = state.help_scroll.saturating_sub(3);
                                }
                                (KeyCode::Down, _) | (KeyCode::PageDown, _) if state.help_open => {
                                    state.help_scroll = state.help_scroll.saturating_add(3);
                                }

                                // Up / Down / Left / Right all navigate the popup when open.
                                // Header rows are skipped automatically.
                                (KeyCode::Up, KeyModifiers::NONE)
                                | (KeyCode::Left, KeyModifiers::NONE)
                                    if has_popup =>
                                {
                                    let mut idx = state.popup_selected.saturating_sub(1);
                                    while idx > 0 && items.get(idx).map(|i| i.is_header).unwrap_or(false) {
                                        idx = idx.saturating_sub(1);
                                    }
                                    if !items.get(idx).map(|i| i.is_header).unwrap_or(true) {
                                        state.popup_selected = idx;
                                    }
                                }
                                (KeyCode::Down, KeyModifiers::NONE)
                                | (KeyCode::Right, KeyModifiers::NONE)
                                    if has_popup =>
                                {
                                    let max = items.len().saturating_sub(1);
                                    let mut idx = (state.popup_selected + 1).min(max);
                                    while idx < max && items.get(idx).map(|i| i.is_header).unwrap_or(false) {
                                        idx = (idx + 1).min(max);
                                    }
                                    if !items.get(idx).map(|i| i.is_header).unwrap_or(true) {
                                        state.popup_selected = idx;
                                    }
                                }

                                // Up/Down — history navigation (bash-style)
                                (KeyCode::Up, KeyModifiers::NONE) => {
                                    state.history_prev();
                                }
                                (KeyCode::Down, KeyModifiers::NONE) => {
                                    state.history_next();
                                }

                                // PageUp/PageDown or Shift+Up/Down — scroll content
                                (KeyCode::PageUp, _) | (KeyCode::Up, KeyModifiers::SHIFT) => {
                                    state.scroll = state.scroll.saturating_add(10);
                                }
                                (KeyCode::PageDown, _) | (KeyCode::Down, KeyModifiers::SHIFT) => {
                                    state.scroll = state.scroll.saturating_sub(10);
                                }

                                // Tab: complete into the input (never submits).
                                // For drill-down parents: opens sub-menu.
                                // For leaf commands: fills full command + space ready to run.
                                (KeyCode::Tab, _) if has_popup => {
                                    let sel = state.popup_selected.min(items.len().saturating_sub(1));
                                    if !items[sel].is_header {
                                        let complete = items[sel].complete.clone();
                                        // Always append space — this opens the sub-menu for parents
                                        // and puts a space after leaf commands for argument entry.
                                        let already_has_space = complete.ends_with(' ');
                                        state.input = if already_has_space {
                                            complete
                                        } else {
                                            format!("{complete} ")
                                        };
                                        state.cursor = state.input.chars().count();
                                        let new_items = state_popup_items(&state);
                                        state.popup_selected = new_items.iter()
                                            .position(|i| !i.is_header)
                                            .unwrap_or(0);
                                    }
                                }

                                // Enter with popup:
                                //   drill-down items  (model / permissions / auth) → navigate into sub-menu
                                //   leaf items        → execute immediately
                                (KeyCode::Enter, KeyModifiers::NONE) if has_popup => {
                                    let sel = state.popup_selected.min(items.len().saturating_sub(1));
                                    if !items[sel].is_header {
                                        let complete = items[sel].complete.clone();
                                        if is_drilldown(&complete) {
                                            // Open sub-menu: append space so popup_items sees the prefix
                                            state.input = format!("{complete} ");
                                            state.cursor = state.input.chars().count();
                                            let new_items = state_popup_items(&state);
                                            state.popup_selected = new_items.iter()
                                                .position(|i| !i.is_header)
                                                .unwrap_or(0);
                                        } else {
                                            // Leaf: execute
                                            state.input = complete;
                                            state.cursor = state.input.chars().count();
                                            state.popup_selected = 0;
                                            let text = state.input_take();
                                            if text.trim() == "/treemap" {
                                                let cwd = std::path::PathBuf::from(&state.cwd);
                                                let map = generate_repo_map(&cwd, 2);
                                                state.push_exec(ExecBlock::SystemMsg(format!("[TREEMAP]\n{}", map)));
                                            } else {
                                                submit_text = Some(text);
                                            }
                                        }
                                    }
                                }
                                (KeyCode::Enter, KeyModifiers::NONE) => {
                                    let text = state.input_take();
                                    if !text.trim().is_empty() {
                                        if text.trim() == "/treemap" {
                                            let cwd = std::path::PathBuf::from(&state.cwd);
                                            let map = generate_repo_map(&cwd, 2);
                                            state.push_exec(ExecBlock::SystemMsg(format!("[TREEMAP]\n{}", map)));
                                        } else {
                                            submit_text = Some(text);
                                        }
                                    }
                                }

                                (KeyCode::Char(c), m)
                                    if m == KeyModifiers::NONE || m == KeyModifiers::SHIFT =>
                                {
                                    state.quit_confirm = false;
                                    state.history_idx = None; // exit history browse on new input
                                    state.input_insert(c);
                                    // Reset to first selectable item (skip any header at 0)
                                    let new_items = state_popup_items(&state);
                                    state.popup_selected = new_items.iter()
                                        .position(|i| !i.is_header)
                                        .unwrap_or(0);
                                }
                                (KeyCode::Backspace, _) => {
                                    state.quit_confirm = false;
                                    state.input_backspace();
                                    let new_items = state_popup_items(&state);
                                    state.popup_selected = new_items.iter()
                                        .position(|i| !i.is_header)
                                        .unwrap_or(0);
                                }
                                (KeyCode::Delete, _) => {
                                    state.quit_confirm = false;
                                    state.input_delete();
                                }

                                // ── Readline shortcuts ────────────────────────
                                // Ctrl+A: jump to start of line
                                (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    state.cursor = 0;
                                }
                                // Ctrl+E: jump to end of line
                                (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    state.cursor = state.input.chars().count();
                                }
                                // Ctrl+K: kill to end of line
                                (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    let pos = state.input.char_indices()
                                        .nth(state.cursor)
                                        .map(|(i, _)| i)
                                        .unwrap_or(state.input.len());
                                    state.input.truncate(pos);
                                }
                                // Ctrl+U: kill to start of line
                                (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    let pos = state.input.char_indices()
                                        .nth(state.cursor)
                                        .map(|(i, _)| i)
                                        .unwrap_or(state.input.len());
                                    state.input.drain(..pos);
                                    state.cursor = 0;
                                }
                                // Ctrl+W: kill previous word
                                (KeyCode::Char('w'), KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    let new_cur = word_left(&state.input, state.cursor);
                                    let start = state.input.char_indices()
                                        .nth(new_cur).map(|(i, _)| i).unwrap_or(0);
                                    let end = state.input.char_indices()
                                        .nth(state.cursor).map(|(i, _)| i)
                                        .unwrap_or(state.input.len());
                                    state.input.drain(start..end);
                                    state.cursor = new_cur;
                                }
                                // Ctrl+L: scroll to bottom (show latest)
                                (KeyCode::Char('l'), KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    state.scroll = 0;
                                }
                                // Ctrl+Left: word left
                                (KeyCode::Left, KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    state.cursor = word_left(&state.input, state.cursor);
                                }
                                // Ctrl+Right: word right
                                (KeyCode::Right, KeyModifiers::CONTROL) => {
                                    state.quit_confirm = false;
                                    state.cursor = word_right(&state.input, state.cursor);
                                }

                                // ── Cursor movement ───────────────────────────
                                (KeyCode::Left, _) => {
                                    state.quit_confirm = false;
                                    if state.cursor > 0 { state.cursor -= 1; }
                                }
                                (KeyCode::Right, _) => {
                                    state.quit_confirm = false;
                                    if state.cursor < state.input.chars().count() {
                                        state.cursor += 1;
                                    }
                                }
                                (KeyCode::Home, _) => {
                                    state.quit_confirm = false;
                                    state.cursor = 0;
                                }
                                (KeyCode::End, _) => {
                                    state.quit_confirm = false;
                                    state.cursor = state.input.chars().count();
                                }
                                _ => {}
                            }
                        }
                        if let Some(ev) = quit_event {
                            let _ = self.event_tx.send(ev);
                        }
                        if let Some(text) = submit_text {
                            let trimmed = text.trim();
                            if trimmed == "/help" || trimmed == "/?" {
                                self.state.lock().unwrap_or_else(|p| p.into_inner()).help_open = true;
                            } else {
                                {
                                    let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                                    state.history_push(&text);
                                    // Show user message immediately for plain chat messages.
                                    // Slash commands and auth-flow inputs are NOT shown as chat bubbles here.
                                    let in_auth = state.auth_flow.is_some();
                                    if !in_auth && !trimmed.starts_with('/') {
                                        state.push_exec(ExecBlock::UserMessage(text.clone()));
                                        state.working = true;
                                        state.scroll = 0;
                                    }
                                }
                                let _ = self.submit_tx.send(text);
                            }
                        }
                        // Handle voice recording toggle outside the state lock
                        match voice_toggle {
                            Some(true) => {
                                // Start recording — try arecord (Linux ALSA)
                                let _ = std::fs::remove_file("/tmp/albert-voice.wav");
                                match std::process::Command::new("arecord")
                                    .args(["-q", "-r", "16000", "-c", "1", "-f", "S16_LE",
                                           "/tmp/albert-voice.wav"])
                                    .spawn()
                                {
                                    Ok(child) => {
                                        *self.voice_process.lock().unwrap_or_else(|p| p.into_inner()) = Some(child);
                                    }
                                    Err(_) => {
                                        // arecord not available
                                        let _ = self.event_tx.send(TuiEvent::VoiceError(
                                            "voice: arecord not found (install alsa-utils)".to_string(),
                                        ));
                                        self.state.lock().unwrap_or_else(|p| p.into_inner()).is_recording = false;
                                    }
                                }
                            }
                            Some(false) => {
                                // Stop recording and transcribe
                                if let Some(mut child) = self.voice_process.lock().unwrap_or_else(|p| p.into_inner()).take() {
                                    let _ = child.kill();
                                    let _ = child.wait();
                                }
                                let tx = self.event_tx.clone();
                                let _ = tx.send(TuiEvent::VoiceTranscribing);
                                tokio::spawn(async move {
                                    const WAV: &str = "/tmp/albert-voice.wav";
                                    let size = std::fs::metadata(WAV).map(|m| m.len()).unwrap_or(0);
                                    if size > 44 {
                                        let result = tokio::time::timeout(
                                            std::time::Duration::from_secs(30),
                                            transcribe(WAV),
                                        ).await;
                                        match result {
                                            Ok(Ok(text)) => { let _ = tx.send(TuiEvent::VoiceText(text)); }
                                            Ok(Err(e))   => { let _ = tx.send(TuiEvent::VoiceError(e)); }
                                            Err(_)       => { let _ = tx.send(TuiEvent::VoiceError(
                                                "voice: transcription timed out after 30s".to_string(),
                                            )); }
                                        }
                                    } else {
                                        let _ = tx.send(TuiEvent::VoiceError(
                                            "voice: no audio captured (is your mic working?)".to_string(),
                                        ));
                                    }
                                });
                            }
                            None => {}
                        }
                    }

                    // ── agent events ──────────────────────────────────────────
                    Some(TuiEvent::AgentEvent(ev)) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        match ev {
                            AssistantEvent::TextDelta(delta) => {
                                // Filter Empty Deltas: Ignore whitespace-only or empty events.
                                if delta.trim().is_empty() && !delta.contains('\n') {
                                    // continue to next event
                                } else {
                                    // Flow incoming text into the typewriter buffer.
                                    // It will be drained character-by-character on Tick events.
                                    state.typewriter_buffer.push_str(&delta);
                                }
                            }
                            AssistantEvent::ToolUse { name, input, .. } => {
                                let preview = tool_input_preview(&input);
                                let is_edit_tool = name.to_lowercase().contains("edit")
                                    || name == "str_replace_based_edit_tool"
                                    || name == "str_replace_editor";
                                let xray = if is_edit_tool {
                                    build_xray_from_edit_full(&input)
                                } else {
                                    None
                                };
                                state.push_exec(ExecBlock::ToolUse {
                                    name,
                                    args: preview,
                                    active: true,
                                    xray,
                                });
                            }
                            AssistantEvent::TaskStarted { id, label } => {
                                // Update existing plan or create a new one
                                let mut found = false;
                                if let Some(ExecBlock::Plan { tasks, frozen: false }) = state.exec_log.back_mut() {
                                    if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
                                        task.status = TaskStatus::Running;
                                        found = true;
                                    } else {
                                        tasks.push(Task { id: id.clone(), label: label.clone(), status: TaskStatus::Running });
                                        found = true;
                                    }
                                }
                                if !found {
                                    state.push_exec(ExecBlock::Plan {
                                        tasks: vec![Task { id, label, status: TaskStatus::Running }],
                                        frozen: false,
                                    });
                                }
                            }
                            AssistantEvent::TaskCompleted { id, success } => {
                                if let Some(ExecBlock::Plan { tasks, frozen: false }) = state.exec_log.back_mut() {
                                    if let Some(task) = tasks.iter_mut().find(|t| t.id == id) {
                                        task.status = if success { TaskStatus::Done } else { TaskStatus::Failed };
                                    }
                                }
                            }
                            AssistantEvent::Usage(usage) => {
                                state.tokens_in = state.tokens_in.max(usage.input_tokens);
                                state.tokens_out += usage.output_tokens;
                            }
                            AssistantEvent::MessageStop => {
                                // Clear anchoring so any subsequent text (in a new turn) starts a new block.
                                state.current_assistant_block_index = None;

                                // Phase 3: Freezer - stop pulsing for current Plan
                                if let Some(ExecBlock::Plan { tasks, frozen }) = state.exec_log.back_mut() {
                                    *frozen = true;
                                    for task in tasks.iter_mut() {
                                        if task.status == TaskStatus::Running {
                                            task.status = TaskStatus::Done;
                                        }
                                    }
                                }
                            }
                            AssistantEvent::Thinking { text, .. } => {
                                if !text.trim().is_empty() {
                                    // Push an empty block immediately so the "thinking" header
                                    // appears right away, then stream text in line by line via Tick.
                                    state.push_exec(ExecBlock::Thinking(String::new()));
                                    state.thinking_typewriter_buffer.push_str(&text);
                                }
                            }
                            AssistantEvent::ToolTelemetry { .. } => {}
                        }
                    }

                    // ── HITL ──────────────────────────────────────────────────
                    Some(TuiEvent::ToolApprovalRequestSync { id, name, input, tx, default_selected }) => {
                        // Communication tools never need user approval — auto-allow them silently.
                        let auto_approve = matches!(
                            name.as_str(),
                            "SendUserMessage" | "send_user_message" | "Brief" | "brief"
                        );
                        let session_ok = {
                            let state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                            state.session_approved_tools.contains(&name)
                        };
                        if auto_approve || session_ok {
                            let _ = tx.send(runtime::PermissionPromptDecision::Allow);
                        } else {
                            let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                            state.hitl_selected = default_selected;
                            state.awaiting_tool_approval = Some(Arc::new(Mutex::new(Some(ToolApprovalState {
                                _id: id,
                                name,
                                input,
                                resp_tx: tx,
                            }))));
                        }
                    }
                    Some(TuiEvent::ToolApprovalResponse { approved, feedback }) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        state.hitl_selected = 0;
                        if let Some(approval_arc) = state.awaiting_tool_approval.take() {
                            if let Some(approval) = approval_arc.lock().unwrap_or_else(|p| p.into_inner()).take() {
                                let is_session = feedback.as_deref() == Some("__session__");
                                let is_changes = feedback.as_deref() == Some("__changes__");
                                if is_session {
                                    state.session_approved_tools.insert(approval.name.clone());
                                }
                                if is_changes {
                                    // Pre-fill input so user can describe what to change
                                    state.input = "Please adjust the previous tool call: ".to_string();
                                    state.cursor = state.input.len();
                                }
                                let decision = if approved {
                                    runtime::PermissionPromptDecision::Allow
                                } else {
                                    let reason = if is_changes {
                                        "user requested changes".to_string()
                                    } else {
                                        "user rejected in TUI".to_string()
                                    };
                                    runtime::PermissionPromptDecision::Deny { reason }
                                };
                                let _ = approval.resp_tx.send(decision);
                            }
                        }
                    }

                    // ── terminal handoff for slash commands ───────────────────
                    Some(TuiEvent::Suspend { ack }) => {
                        self.key_paused.store(true, Ordering::Relaxed);
                        io::stdout().execute(DisableMouseCapture).ok();
                        io::stdout().execute(DisableBracketedPaste).ok();
                        disable_raw_mode().ok();
                        io::stdout().execute(LeaveAlternateScreen).ok();
                        io::stdout().flush().ok();
                        let _ = ack.send(());
                        loop {
                            match self.event_rx.recv().await {
                                Some(TuiEvent::Resume) => break,
                                Some(TuiEvent::Quit) | Some(TuiEvent::QuitWithReport) | None => {
                                     self.key_paused.store(false, Ordering::Relaxed);
                                     return Ok::<(), Box<dyn std::error::Error>>(());
                                }
                                _ => {}
                            }
                        }
                        enable_raw_mode().ok();
                        io::stdout().execute(EnableBracketedPaste).ok();
                        io::stdout().execute(EnableMouseCapture).ok();
                        io::stdout().execute(EnterAlternateScreen).ok();
                        terminal.clear().ok();
                        self.key_paused.store(false, Ordering::Relaxed);
                    }

                    // ── voice transcription result ────────────────────────────
                    Some(TuiEvent::VoiceTranscribing) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        state.voice_transcribing = true;
                    }
                    Some(TuiEvent::VoiceText(text)) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        state.is_recording = false;
                        state.voice_transcribing = false;
                        for ch in text.trim().chars() {
                            state.input_insert(ch);
                        }
                    }
                    Some(TuiEvent::VoiceError(msg)) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        state.is_recording = false;
                        state.voice_transcribing = false;
                        state.push_exec(ExecBlock::SystemMsg(msg));
                    }

                    // ── bracketed paste ───────────────────────────────────────
                    Some(TuiEvent::PasteText(text)) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        let line_count = text.lines().count();
                        if line_count > 1 || text.chars().count() > 2000 {
                            // Multi-line paste: store raw, show compact badge in render_input.
                            state.input = text;
                            state.cursor = 0; // Keep cursor at 0 so it stays on the badge
                            state.paste_line_count = Some(line_count);
                        } else {
                            // Reasonable paste: insert inline, convert newlines to spaces for now
                            // since the input box is still optimized for single-line display.
                            state.paste_line_count = None;
                            for ch in text.chars() {
                                if ch == '\n' || ch == '\r' {
                                    state.input_insert(' ');
                                } else {
                                    state.input_insert(ch);
                                }
                            }
                        }
                    }

                    Some(TuiEvent::ScrollUp) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        state.scroll = state.scroll.saturating_add(5);
                    }
                    Some(TuiEvent::ScrollDown) => {
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                        state.scroll = state.scroll.saturating_sub(5);
                    }

                    Some(TuiEvent::Tick) | Some(TuiEvent::Resume) => {
                        // Tick fires at 100ms — redraws the screen (spinner, timer).
                        let mut state = self.state.lock().unwrap_or_else(|p| p.into_inner());

                        // Advance spine animation frame when working (cycles │ ╎ ┆ ┊ at ~2Hz)
                        if state.working {
                            state.spine_frame = state.spine_frame.wrapping_add(1);
                        }

                        // Adaptive Typewriter: Smoother flow by scaling drain rate with buffer size.
                        if !state.typewriter_buffer.is_empty() {
                            let buf_len = state.typewriter_buffer.chars().count();
                            // Drain faster if buffer is full (up to 40 chars/tick), minimum 5 for visibility.
                            let n = if buf_len > 50 { 25 } else if buf_len > 20 { 15 } else { 5 };
                            let n = n.min(buf_len);

                            let chars: String = state.typewriter_buffer.chars().take(n).collect();
                            state.typewriter_buffer = state.typewriter_buffer.chars().skip(n).collect();

                            let mut appended = false;
                            if let Some(idx) = state.current_assistant_block_index {
                                if let Some(ExecBlock::AgentText(s, _)) = state.exec_log.get_mut(idx) {
                                    s.push_str(&chars);
                                    appended = true;
                                }
                            }

                            if !appended {
                                state.push_exec(ExecBlock::AgentText(chars, false));
                            }
                        }

                        // Thinking Typewriter: drain line by line for clean step-by-step visibility.
                        if !state.thinking_typewriter_buffer.is_empty() {
                            let chunk = if let Some(nl) = state.thinking_typewriter_buffer.find('\n') {
                                // Drain up to and including the next newline.
                                state.thinking_typewriter_buffer[..=nl].to_string()
                            } else {
                                // No newline left — drain the remainder.
                                state.thinking_typewriter_buffer.clone()
                            };
                            let chunk_len = chunk.len();
                            state.thinking_typewriter_buffer.drain(..chunk_len);

                            if let Some(idx) = state.current_thinking_block_index {
                                if let Some(ExecBlock::Thinking(s)) = state.exec_log.get_mut(idx) {
                                    s.push_str(&chunk);
                                }
                            }
                        }
                    }
                    Some(TuiEvent::Quit) => {
                        break;
                    }

                    Some(TuiEvent::QuitWithReport) => {
                        // Show report card and wait for any keypress before exiting
                        {
                            let state = self.state.lock().unwrap_or_else(|p| p.into_inner());
                            terminal.draw(|f| render_report_card(f, &state))?;
                        }
                        // Drain any pending keys then wait for a fresh one
                        while self.event_rx.try_recv().is_ok() {}
                        loop {
                            match self.event_rx.recv().await {
                                Some(TuiEvent::Key(_)) | Some(TuiEvent::Quit) | None => break,
                                _ => {}
                            }
                        }
                        break;
                    }
                    None => break,
                }
            }
            Ok::<(), Box<dyn std::error::Error>>(())
        })?;

        io::stdout().execute(DisableMouseCapture).ok();
        io::stdout().execute(DisableBracketedPaste).ok();
        disable_raw_mode().ok();
        io::stdout().execute(LeaveAlternateScreen).ok();
        Ok(())
    }
}



fn render_hitl_panel(f: &mut ratatui::Frame, area: Rect, name: &str, input: &serde_json::Value, selected: usize) {
    // selected == 3 means Deny is pre-highlighted → this is a restricted mode (ReadOnly / workspace-write).
    use ratatui::widgets::Clear;
    f.render_widget(Clear, area);

    // Split horizontally: left = tool preview, right = option list
    let split = Layout::default()
        .direction(Direction::Horizontal)
        .constraints([Constraint::Min(10), Constraint::Length(30)])
        .split(area);

    // ── Left: tool preview ──────────────────────────────────────────────────
    let left_block = Block::default()
        .title(format!(" ⚠ {} ", name))
        .borders(Borders::ALL)
        .border_style(Style::default().fg(ORANGE).add_modifier(Modifier::BOLD))
        .style(Style::default().bg(POPUP_BG));
    let left_inner = left_block.inner(split[0]);
    f.render_widget(left_block, split[0]);

    let tool = name.to_lowercase();
    let mut preview: Vec<Line<'static>> = Vec::new();

    if tool.contains("edit") || tool == "edit" {
        let path = input.get("file_path").and_then(|v| v.as_str()).unwrap_or("?");
        preview.push(Line::from(vec![
            Span::styled("file  ", Style::default().fg(GREY)),
            Span::styled(path.to_string(), Style::default().fg(CYAN).add_modifier(Modifier::BOLD)),
        ]));
        if let Some(old) = input.get("old_string").and_then(|v| v.as_str()) {
            for l in old.lines().take(1) {
                preview.push(Line::from(vec![
                    Span::styled("- ", Style::default().fg(Color::Red)),
                    Span::styled(l.trim_end().to_string(), Style::default().fg(Color::Rgb(220, 100, 100))),
                ]));
            }
            if old.lines().count() > 1 {
                preview.push(Line::from(Span::styled(format!("  … {} more removed", old.lines().count() - 1), Style::default().fg(GREY))));
            }
        }
        if let Some(new) = input.get("new_string").and_then(|v| v.as_str()) {
            for l in new.lines().take(1) {
                preview.push(Line::from(vec![
                    Span::styled("+ ", Style::default().fg(Color::Green)),
                    Span::styled(l.trim_end().to_string(), Style::default().fg(Color::Rgb(100, 220, 120))),
                ]));
            }
            if new.lines().count() > 1 {
                preview.push(Line::from(Span::styled(format!("  … {} more added", new.lines().count() - 1), Style::default().fg(GREY))));
            }
        }
    } else if tool.contains("write") {
        let path = input.get("file_path").and_then(|v| v.as_str()).unwrap_or("?");
        let content = input.get("content").and_then(|v| v.as_str()).unwrap_or("");
        preview.push(Line::from(vec![
            Span::styled("write  ", Style::default().fg(GREY)),
            Span::styled(path.to_string(), Style::default().fg(Color::Green).add_modifier(Modifier::BOLD)),
        ]));
        preview.push(Line::from(Span::styled(format!("{} lines", content.lines().count()), Style::default().fg(GREY))));
    } else if tool.contains("bash") || tool.contains("shell") {
        let cmd = input.get("command").and_then(|v| v.as_str()).unwrap_or("?");
        for l in cmd.lines().take(3) {
            preview.push(Line::from(vec![
                Span::styled("$ ", Style::default().fg(CYAN)),
                Span::styled(l.trim_end().to_string(), Style::default().fg(FG)),
            ]));
        }
        if cmd.lines().count() > 3 {
            preview.push(Line::from(Span::styled(format!("  … {} more lines", cmd.lines().count() - 3), Style::default().fg(GREY))));
        }
    } else if tool.contains("read") {
        let path = input.get("file_path").and_then(|v| v.as_str()).unwrap_or("?");
        preview.push(Line::from(vec![
            Span::styled("read  ", Style::default().fg(GREY)),
            Span::styled(path.to_string(), Style::default().fg(CYAN)),
        ]));
    } else {
        if let Some(obj) = input.as_object() {
            for (k, v) in obj.iter().take(3) {
                let val = match v {
                    serde_json::Value::String(s) => {
                        let first = s.lines().next().unwrap_or(s.as_str());
                        if first.len() > 40 { format!("{}…", &first[..40]) } else { first.to_string() }
                    }
                    other => { let s = other.to_string(); if s.len() > 40 { format!("{}…", &s[..40]) } else { s } }
                };
                preview.push(Line::from(vec![
                    Span::styled(format!("{}: ", k), Style::default().fg(GREY)),
                    Span::styled(val, Style::default().fg(FG)),
                ]));
            }
        }
    }

    f.render_widget(
        Paragraph::new(ratatui::text::Text::from(preview))
            .wrap(Wrap { trim: true })
            .style(Style::default().fg(FG).bg(POPUP_BG)),
        left_inner,
    );

    // ── Right: option list ──────────────────────────────────────────────────
    let right_block = Block::default()
        .borders(Borders::ALL)
        .border_style(Style::default().fg(ORANGE))
        .style(Style::default().bg(POPUP_BG));
    let right_inner = right_block.inner(split[1]);
    f.render_widget(right_block, split[1]);

    const OPTIONS: [(&str, &str); 4] = [
        ("✓",  "Approve"),
        ("✓✓", "Approve for session"),
        ("~",  "Approve with changes"),
        ("✗",  "Deny"),
    ];

    let opt_chunks = Layout::default()
        .direction(Direction::Vertical)
        .constraints([
            Constraint::Length(1), Constraint::Length(1),
            Constraint::Length(1), Constraint::Length(1),
            Constraint::Min(1),
        ])
        .split(right_inner);

    for (i, (glyph, label)) in OPTIONS.iter().enumerate() {
        let is_sel = i == selected;
        let bg = if is_sel { POPUP_SEL_BG } else { POPUP_BG };
        let fg_col = if is_sel { GREEN } else { GREY };
        let arrow = if is_sel { "▶ " } else { "  " };
        let glyph_col = match i {
            0 => Color::Green,
            1 => CYAN,
            2 => ORANGE,
            _ => Color::Red,
        };
        f.render_widget(
            Paragraph::new(Line::from(vec![
                Span::styled(arrow.to_string(), Style::default().fg(GREEN).bg(bg)),
                Span::styled(format!("{} ", glyph), Style::default().fg(glyph_col).bg(bg).add_modifier(Modifier::BOLD)),
                Span::styled(label.to_string(), Style::default().fg(fg_col).bg(bg)),
            ])).style(Style::default().bg(bg)),
            opt_chunks[i],
        );
    }

    f.render_widget(
        Paragraph::new(Line::from(Span::styled(
            "  ↑↓ nav  ·  enter  ·  esc=deny",
            Style::default().fg(DIM).bg(POPUP_BG),
        ))),
        opt_chunks[4],
    );
}

// ── XRay diff helpers ─────────────────────────────────────────────────────────


/// Find the 1-based line number where `needle` starts in `haystack`.
fn find_xray_base_line(haystack: &str, needle: &str) -> Option<usize> {
    let pos = haystack.find(needle)?;
    let line = haystack[..pos].lines().count() + 1;
    Some(line)
}

/// Diff operation produced by LCS.
#[derive(Debug)]
#[allow(dead_code)]
enum DiffOp {
    Context(usize, usize), // (old_idx, new_idx)
    Removed(usize),        // old_idx
    Added(usize),          // new_idx
}

/// Simple LCS-based diff. Returns (ops, added_count, removed_count).
fn lcs_diff<'a>(old: &'a [&str], new: &'a [&str]) -> (Vec<DiffOp>, usize, usize) {
    let m = old.len();
    let n = new.len();

    // Build LCS DP table
    let mut dp = vec![vec![0u16; n + 1]; m + 1];
    for i in (0..m).rev() {
        for j in (0..n).rev() {
            dp[i][j] = if old[i] == new[j] {
                dp[i + 1][j + 1].saturating_add(1)
            } else {
                dp[i + 1][j].max(dp[i][j + 1])
            };
        }
    }

    // Traceback
    let mut ops = Vec::new();
    let (mut i, mut j) = (0, 0);
    let mut added = 0usize;
    let mut removed = 0usize;
    while i < m || j < n {
        if i < m && j < n && old[i] == new[j] {
            ops.push(DiffOp::Context(i, j));
            i += 1; j += 1;
        } else if j < n && (i >= m || dp[i][j + 1] >= dp[i + 1][j]) {
            ops.push(DiffOp::Added(j));
            added += 1;
            j += 1;
        } else {
            ops.push(DiffOp::Removed(i));
            removed += 1;
            i += 1;
        }
    }
    (ops, added, removed)
}


/// Build xray diff with actual text content filled in.
pub fn build_xray_from_edit_full(input_json: &str) -> Option<XRayDiff> {
    let val: serde_json::Value = serde_json::from_str(input_json).ok()?;
    let old_str = val.get("old_string").and_then(|v| v.as_str())?;
    let new_str = val.get("new_string").and_then(|v| v.as_str())?;
    let file_path = val.get("file_path").and_then(|v| v.as_str()).unwrap_or("");

    let old_lines: Vec<&str> = old_str.lines().collect();
    let new_lines: Vec<&str> = new_str.lines().collect();

    if old_lines.len() > 300 || new_lines.len() > 300 {
        return None;
    }

    let base_line: usize = if !file_path.is_empty() {
        std::fs::read_to_string(file_path)
            .ok()
            .and_then(|content| find_xray_base_line(&content, old_str))
            .unwrap_or(1)
    } else { 1 };

    let short_name = std::path::Path::new(file_path)
        .file_name()
        .and_then(|n| n.to_str())
        .unwrap_or(file_path)
        .to_string();

    let (ops, added, removed) = lcs_diff(&old_lines, &new_lines);

    const CTX: usize = 3;
    let n = ops.len();
    let mut interesting = vec![false; n];
    for (i, op) in ops.iter().enumerate() {
        if matches!(op, DiffOp::Added(_) | DiffOp::Removed(_)) {
            let lo = i.saturating_sub(CTX);
            let hi = (i + CTX + 1).min(n);
            for k in lo..hi { interesting[k] = true; }
        }
    }

    let mut xray_lines = Vec::new();
    let mut skip_count = 0usize;

    for (i, op) in ops.iter().enumerate() {
        if !interesting[i] { skip_count += 1; continue; }
        if skip_count > 0 {
            xray_lines.push(XRayLine::Elided { count: skip_count });
            skip_count = 0;
        }
        match op {
            DiffOp::Context(oi, _) => xray_lines.push(XRayLine::Context {
                n: base_line + oi,
                text: old_lines[*oi].to_string(),
            }),
            DiffOp::Removed(oi) => xray_lines.push(XRayLine::Removed {
                n: base_line + oi,
                text: old_lines[*oi].to_string(),
            }),
            DiffOp::Added(ni) => xray_lines.push(XRayLine::Added {
                n: base_line + ni,
                text: new_lines[*ni].to_string(),
            }),
        }
    }
    if skip_count > 0 { xray_lines.push(XRayLine::Elided { count: skip_count }); }

    Some(XRayDiff { file: short_name, added, removed, lines: xray_lines })
}
