use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;

#[cfg(test)]
use crate::product::protocol::plan_tool::PlanItemArg;
#[cfg(test)]
use crate::product::protocol::plan_tool::StepStatus;
#[cfg(test)]
use crate::product::protocol::plan_tool::UpdatePlanArgs;
use crate::product::tui_app::history_cell::HistoryCell;
use crate::product::tui_app::history_cell::ProposedPlanStreamCell;
use crate::product::tui_app::history_cell::UserHistoryCell;
use crate::product::tui_app::history_cell::is_proposed_plan_cell;
#[cfg(test)]
use crate::product::tui_app::history_cell::new_plan_update;
#[cfg(test)]
use crate::product::tui_app::history_cell::new_proposed_plan;
#[cfg(test)]
use crate::product::tui_app::history_cell::new_proposed_plan_stream;
#[cfg(test)]
use crate::product::tui_app::history_cell::new_user_prompt;
#[cfg(test)]
use crate::product::tui_app::history_cell::proposed_plan_trailing_body_gap_line;
use crate::product::tui_app::history_cell::render_line_backgrounds;
use crate::product::tui_app::mouse::MouseScrollState;
use crate::product::tui_app::mouse::ScrollDirection;
use crate::product::tui_app::render::renderable::Renderable;
#[cfg(test)]
use crate::product::tui_app::style::proposed_plan_style;
use crate::product::tui_app::style::transcript_selection_style;
use crate::product::tui_app::style::user_message_style;
use crate::product::tui_app::terminal_palette;
use crate::product::tui_app::transcript_selection::TranscriptSelection;
use crate::product::tui_app::transcript_selection::TranscriptSelectionPoint;
use crossterm::event::MouseButton;
use crossterm::event::MouseEvent;
use crossterm::event::MouseEventKind;
use ratatui::buffer::Buffer;
use ratatui::buffer::Cell;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Text;
use ratatui::widgets::Block;
use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::Wrap;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;

const DRAG_AUTOSCROLL_LINES_PER_TICK: isize = 1;
const DRAG_AUTOSCROLL_EDGE_ROWS: u16 = 1;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TranscriptLiveTailSource {
    ActiveCell,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct TranscriptLiveTailKey {
    source: TranscriptLiveTailSource,
    width: u16,
    revision: u64,
    is_stream_continuation: bool,
    animation_tick: Option<u64>,
}

impl TranscriptLiveTailKey {
    pub(crate) fn new(
        source: TranscriptLiveTailSource,
        width: u16,
        revision: u64,
        is_stream_continuation: bool,
        animation_tick: Option<u64>,
    ) -> Self {
        Self {
            source,
            width,
            revision,
            is_stream_continuation,
            animation_tick,
        }
    }

    fn with_width(self, width: u16) -> Self {
        Self { width, ..self }
    }

    pub(crate) fn animation_tick(self) -> Option<u64> {
        self.animation_tick
    }
}

pub(crate) struct TranscriptLiveTail {
    pub(crate) lines: Vec<Line<'static>>,
    pub(crate) renderable: Box<dyn Renderable>,
}

type TranscriptRenderableList = Vec<Box<dyn Renderable>>;
type CellLayoutRef = Rc<RefCell<CellLayoutInfo>>;
type CellLayoutList = Vec<CellLayoutRef>;

#[derive(Clone)]
struct TranscriptLiveTailSpacing {
    prior_visible_layout: Option<CellLayoutRef>,
    is_stream_continuation: bool,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct ScrollAnchor {
    chunk_index: usize,
    intra_chunk_row: usize,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct DragAutoScroll {
    column: u16,
    direction: ScrollDirection,
    lines_per_tick: isize,
}

#[derive(Clone, Debug, PartialEq, Eq)]
struct TranscriptVisualLine {
    plain: String,
    display_width: usize,
}

impl TranscriptVisualLine {
    fn from_plain(plain: String) -> Self {
        let display_width = UnicodeWidthStr::width(plain.as_str());
        Self {
            plain,
            display_width,
        }
    }

    fn blank() -> Self {
        Self::from_plain(String::new())
    }

    fn is_blank(&self) -> bool {
        self.plain.trim().is_empty()
    }
}

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum TranscriptScroll {
    Up,
    Down,
    PageUp,
    PageDown,
    HalfPageUp,
    HalfPageDown,
    Home,
    End,
}

#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum TranscriptRenderMode {
    #[default]
    Display,
    Transcript,
}

#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum TranscriptMouseOutcome {
    Ignored,
    Scrolled,
    SelectionChanged,
    SelectionCompleted(Option<String>),
}

#[derive(Default)]
pub(crate) struct TranscriptView {
    mode: TranscriptRenderMode,
    cells: Vec<Arc<dyn HistoryCell>>,
    renderables: TranscriptRenderableList,
    cell_layouts: CellLayoutList,
    scroll_offset: usize,
    stick_to_bottom: bool,
    user_scrolled_during_stream: bool,
    last_content_height: Option<usize>,
    last_rendered_height: Option<usize>,
    pending_scroll_chunk: Option<usize>,
    highlight_cell: Option<usize>,
    live_tail_key: Option<TranscriptLiveTailKey>,
    live_tail_spacing: Option<Rc<RefCell<TranscriptLiveTailSpacing>>>,
    live_tail_lines: Vec<Line<'static>>,
    last_width: Option<u16>,
    last_area: Option<Rect>,
    last_top_line: usize,
    last_total_lines: usize,
    last_padding_top: usize,
    selection: TranscriptSelection,
    drag_autoscroll: Option<DragAutoScroll>,
}

impl TranscriptView {
    pub(crate) fn new(cells: Vec<Arc<dyn HistoryCell>>, mode: TranscriptRenderMode) -> Self {
        let (renderables, cell_layouts) = Self::render_cells(&cells, None, mode);
        Self {
            mode,
            renderables,
            cell_layouts,
            cells,
            scroll_offset: 0,
            stick_to_bottom: true,
            user_scrolled_during_stream: false,
            last_content_height: None,
            last_rendered_height: None,
            pending_scroll_chunk: None,
            highlight_cell: None,
            live_tail_key: None,
            live_tail_spacing: None,
            live_tail_lines: Vec::new(),
            last_width: None,
            last_area: None,
            last_top_line: 0,
            last_total_lines: 0,
            last_padding_top: 0,
            selection: TranscriptSelection::default(),
            drag_autoscroll: None,
        }
    }

    #[cfg(test)]
    fn new_transcript(cells: Vec<Arc<dyn HistoryCell>>) -> Self {
        Self::new(cells, TranscriptRenderMode::Transcript)
    }

    pub(crate) fn insert_cell(&mut self, cell: Arc<dyn HistoryCell>) {
        let follow_bottom = self.is_scrolled_to_bottom();
        let spacing_width = self.last_width.unwrap_or(u16::MAX).max(1);
        let inserted_cell_visible = self.mode.is_visible(cell.as_ref(), spacing_width);
        let prior_visible_layout = self.prior_visible_committed_layout(u16::MAX);
        let tail_renderable = self.take_live_tail_renderable();
        let cell_index = self.cells.len();
        let layout = Rc::new(RefCell::new(CellLayoutInfo::default()));
        let renderable = Self::render_cell(
            cell_index,
            cell.clone(),
            prior_visible_layout,
            self.highlight_cell,
            self.mode,
            layout.clone(),
        );
        self.cells.push(cell.clone());
        self.cell_layouts.push(layout);
        self.renderables.push(renderable);
        if let Some(tail) = tail_renderable {
            if inserted_cell_visible && let Some(spacing) = &self.live_tail_spacing {
                spacing.borrow_mut().prior_visible_layout = self.cell_layouts.last().map(Rc::clone);
            }
            self.renderables.push(tail);
        }
        if follow_bottom {
            self.stick_to_bottom = true;
            self.user_scrolled_during_stream = false;
        }
    }

    pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
        self.highlight_cell = cell;
        self.rebuild_renderables();
        if let Some(idx) = self.highlight_cell {
            self.pending_scroll_chunk = Some(idx);
        }
    }

    pub(crate) fn sync_live_tail(
        &mut self,
        width: u16,
        live_tail_key: Option<TranscriptLiveTailKey>,
        compute_tail: impl FnOnce(u16) -> Option<TranscriptLiveTail>,
    ) {
        let next_key = live_tail_key.map(|key| key.with_width(width));

        if self.live_tail_key == next_key {
            return;
        }

        let follow_bottom = self.is_scrolled_to_bottom();
        self.take_live_tail_renderable();
        self.live_tail_key = next_key;
        self.live_tail_spacing = None;
        self.live_tail_lines.clear();

        if let Some(key) = next_key
            && let Some(tail) = compute_tail(width)
            && !tail.lines.is_empty()
        {
            let spacing = Rc::new(RefCell::new(TranscriptLiveTailSpacing {
                prior_visible_layout: self.prior_visible_committed_layout(width),
                is_stream_continuation: key.is_stream_continuation,
            }));
            let renderable = Box::new(ConditionalTopGapRenderable::new(
                tail.renderable,
                spacing.clone(),
            ));
            self.renderables.push(renderable);
            self.live_tail_spacing = Some(spacing);
            self.live_tail_lines = tail.lines;
        }

        if follow_bottom {
            self.stick_to_bottom = true;
            self.user_scrolled_during_stream = false;
        }
    }

    pub(crate) fn desired_height(&self, width: u16) -> u16 {
        self.renderables
            .iter()
            .fold(0_u32, |acc, renderable| {
                acc.saturating_add(u32::from(renderable.desired_height(width)))
            })
            .min(u32::from(u16::MAX)) as u16
    }

    pub(crate) fn render_inline(&mut self, area: Rect, buf: &mut Buffer) {
        self.render_area(area, buf, false, true);
    }

    pub(crate) fn render_overlay_content(&mut self, area: Rect, buf: &mut Buffer) {
        self.render_area(area, buf, true, false);
    }

    pub(crate) fn handle_mouse_event(
        &mut self,
        mouse: MouseEvent,
        scroll_state: &mut MouseScrollState,
    ) -> TranscriptMouseOutcome {
        match mouse.kind {
            MouseEventKind::ScrollUp => {
                if !self.last_area_contains(mouse.column, mouse.row) {
                    return TranscriptMouseOutcome::Ignored;
                }
                let update = scroll_state.on_scroll(ScrollDirection::Up);
                if self.apply_scroll_delta(update.delta_lines) {
                    TranscriptMouseOutcome::Scrolled
                } else {
                    TranscriptMouseOutcome::Ignored
                }
            }
            MouseEventKind::ScrollDown => {
                if !self.last_area_contains(mouse.column, mouse.row) {
                    return TranscriptMouseOutcome::Ignored;
                }
                let update = scroll_state.on_scroll(ScrollDirection::Down);
                if self.apply_scroll_delta(update.delta_lines) {
                    TranscriptMouseOutcome::Scrolled
                } else {
                    TranscriptMouseOutcome::Ignored
                }
            }
            MouseEventKind::Down(MouseButton::Left) => {
                if let Some(point) = self.selection_point_from_position(mouse.column, mouse.row) {
                    self.selection.anchor = Some(point);
                    self.selection.head = Some(point);
                    self.selection.dragging = true;
                    self.drag_autoscroll = None;
                    TranscriptMouseOutcome::SelectionChanged
                } else if self.selection.is_active() {
                    self.selection.clear();
                    self.drag_autoscroll = None;
                    TranscriptMouseOutcome::SelectionChanged
                } else {
                    TranscriptMouseOutcome::Ignored
                }
            }
            MouseEventKind::Drag(MouseButton::Left) => {
                if !self.selection.dragging {
                    return TranscriptMouseOutcome::Ignored;
                }

                if let Some((point, autoscroll)) =
                    self.drag_selection_point_from_position(mouse.column, mouse.row)
                {
                    self.selection.head = Some(point);
                    self.drag_autoscroll = autoscroll;
                    TranscriptMouseOutcome::SelectionChanged
                } else {
                    TranscriptMouseOutcome::Ignored
                }
            }
            MouseEventKind::Up(MouseButton::Left) if self.selection.dragging => {
                self.selection.dragging = false;
                self.drag_autoscroll = None;
                TranscriptMouseOutcome::SelectionCompleted(self.selection_to_text())
            }
            _ => TranscriptMouseOutcome::Ignored,
        }
    }

    pub(crate) fn advance_drag_autoscroll(&mut self, area: Rect) -> bool {
        let Some(autoscroll) = self.drag_autoscroll else {
            return false;
        };
        if !self.selection.dragging || area.is_empty() {
            self.drag_autoscroll = None;
            return false;
        }

        let delta = match autoscroll.direction {
            ScrollDirection::Up => -autoscroll.lines_per_tick,
            ScrollDirection::Down => autoscroll.lines_per_tick,
        };
        let changed_scroll = self.apply_scroll_delta(delta);
        let Some(point) = self.edge_selection_point_for_top(
            area,
            autoscroll.direction,
            autoscroll.column,
            self.scroll_offset,
        ) else {
            self.drag_autoscroll = None;
            return changed_scroll;
        };
        let changed_selection = self.selection.head != Some(point);
        self.selection.head = Some(point);
        changed_scroll || changed_selection
    }

    pub(crate) fn drag_autoscroll_active(&self) -> bool {
        self.drag_autoscroll.is_some() && self.selection.dragging
    }

    pub(crate) fn selection_dragging(&self) -> bool {
        self.selection.dragging
    }

    pub(crate) fn clear_selection(&mut self) -> bool {
        let changed =
            self.selection.is_active() || self.selection.dragging || self.drag_autoscroll.is_some();
        self.selection.clear();
        self.drag_autoscroll = None;
        changed
    }

    pub(crate) fn selected_text(&self) -> Option<String> {
        self.selection_to_text().filter(|text| !text.is_empty())
    }

    #[cfg(test)]
    pub(crate) fn selection_active_for_test(&self) -> bool {
        self.selection.is_active()
    }

    #[cfg(test)]
    pub(crate) fn set_selection_for_test(
        &mut self,
        anchor: TranscriptSelectionPoint,
        head: TranscriptSelectionPoint,
    ) {
        self.selection.anchor = Some(anchor);
        self.selection.head = Some(head);
        self.selection.dragging = false;
    }

    pub(crate) fn apply_scroll(&mut self, command: TranscriptScroll) -> bool {
        let old = self.scroll_offset;
        let old_stick_to_bottom = self.stick_to_bottom;
        let old_user_scrolled_during_stream = self.user_scrolled_during_stream;
        let was_at_bottom = self.is_scrolled_to_bottom();
        match command {
            TranscriptScroll::Up => {
                self.scroll_offset = self.scroll_offset.saturating_sub(1);
                self.update_after_upward_scroll(old, was_at_bottom);
            }
            TranscriptScroll::Down => {
                self.stick_to_bottom = false;
                self.user_scrolled_during_stream = false;
                self.scroll_offset = self.scroll_offset.saturating_add(1);
            }
            TranscriptScroll::PageUp => {
                let page = self.page_height();
                self.scroll_offset = self.scroll_offset.saturating_sub(page);
                self.update_after_upward_scroll(old, was_at_bottom);
            }
            TranscriptScroll::PageDown => {
                self.stick_to_bottom = false;
                self.user_scrolled_during_stream = false;
                let page = self.page_height();
                self.scroll_offset = self.scroll_offset.saturating_add(page);
            }
            TranscriptScroll::HalfPageUp => {
                let half_page = self.page_height().saturating_add(1) / 2;
                self.scroll_offset = self.scroll_offset.saturating_sub(half_page);
                self.update_after_upward_scroll(old, was_at_bottom);
            }
            TranscriptScroll::HalfPageDown => {
                self.stick_to_bottom = false;
                self.user_scrolled_during_stream = false;
                let half_page = self.page_height().saturating_add(1) / 2;
                self.scroll_offset = self.scroll_offset.saturating_add(half_page);
            }
            TranscriptScroll::Home => {
                self.scroll_offset = 0;
                self.update_after_upward_scroll(old, was_at_bottom);
            }
            TranscriptScroll::End => {
                self.stick_to_bottom = true;
                self.user_scrolled_during_stream = false;
            }
        }
        self.scroll_offset != old
            || self.stick_to_bottom != old_stick_to_bottom
            || self.user_scrolled_during_stream != old_user_scrolled_during_stream
    }

    pub(crate) fn apply_scroll_delta(&mut self, delta_lines: isize) -> bool {
        if delta_lines == 0 {
            return false;
        }
        let old = self.scroll_offset;
        let old_stick_to_bottom = self.stick_to_bottom;
        let old_user_scrolled_during_stream = self.user_scrolled_during_stream;
        let was_at_bottom = self.is_scrolled_to_bottom();
        let page_height = self.page_height();
        let max_scroll = self
            .last_rendered_height
            .unwrap_or_else(|| self.content_height(self.last_width.unwrap_or(1).max(1)))
            .saturating_sub(page_height);

        let current = if self.stick_to_bottom {
            max_scroll
        } else {
            self.scroll_offset.min(max_scroll)
        };

        if delta_lines < 0 {
            let before = self.scroll_offset;
            self.scroll_offset = current.saturating_sub(delta_lines.unsigned_abs());
            if self.scroll_offset != before {
                self.stick_to_bottom = false;
                self.user_scrolled_during_stream = true;
            } else if was_at_bottom {
                self.stick_to_bottom = true;
                self.user_scrolled_during_stream = false;
            }
        } else {
            self.scroll_offset = current.saturating_add(delta_lines as usize).min(max_scroll);
            if self.scroll_offset >= max_scroll {
                self.stick_to_bottom = true;
                self.user_scrolled_during_stream = false;
            } else {
                self.stick_to_bottom = false;
                self.user_scrolled_during_stream = true;
            }
        }

        self.scroll_offset != old
            || self.stick_to_bottom != old_stick_to_bottom
            || self.user_scrolled_during_stream != old_user_scrolled_during_stream
    }

    pub(crate) fn scroll_to_bottom(&mut self) -> bool {
        let old = self.scroll_offset;
        let old_stick_to_bottom = self.stick_to_bottom;
        let old_user_scrolled_during_stream = self.user_scrolled_during_stream;

        self.stick_to_bottom = true;
        self.user_scrolled_during_stream = false;
        if let (Some(total_height), Some(content_height)) =
            (self.last_rendered_height, self.last_content_height)
        {
            self.scroll_offset = total_height.saturating_sub(content_height);
        }

        self.scroll_offset != old
            || self.stick_to_bottom != old_stick_to_bottom
            || self.user_scrolled_during_stream != old_user_scrolled_during_stream
    }

    pub(crate) fn scroll_to_latest_proposed_plan(&mut self) -> bool {
        let Some(mut target_idx) = self
            .cells
            .iter()
            .rposition(|cell| is_proposed_plan_cell(cell.as_ref()))
        else {
            return false;
        };

        while target_idx > 0
            && self.cells[target_idx]
                .as_any()
                .is::<ProposedPlanStreamCell>()
            && self.cells[target_idx].is_stream_continuation()
            && self.cells[target_idx - 1]
                .as_any()
                .is::<ProposedPlanStreamCell>()
        {
            target_idx -= 1;
        }

        let width = self.last_width.unwrap_or(80).max(1);
        let target_offset = self
            .renderables
            .iter()
            .take(target_idx)
            .map(|renderable| renderable.desired_height(width) as usize)
            .sum::<usize>();
        let max_scroll = self
            .last_rendered_height
            .unwrap_or_else(|| self.content_height(width))
            .saturating_sub(self.last_content_height.unwrap_or(1));
        self.scroll_offset = target_offset.min(max_scroll);
        if self.scroll_offset >= max_scroll {
            self.stick_to_bottom = true;
            self.user_scrolled_during_stream = false;
        } else {
            self.stick_to_bottom = false;
            self.user_scrolled_during_stream = true;
        }
        true
    }

    pub(crate) fn is_scrolled_to_bottom(&self) -> bool {
        if self.stick_to_bottom {
            return true;
        }
        if self.user_scrolled_during_stream {
            return false;
        }
        let Some(height) = self.last_content_height else {
            return false;
        };
        if self.renderables.is_empty() {
            return true;
        }
        let Some(total_height) = self.last_rendered_height else {
            return false;
        };
        if total_height <= height {
            return true;
        }
        let max_scroll = total_height.saturating_sub(height);
        self.scroll_offset >= max_scroll
    }

    pub(crate) fn scroll_percent(&self) -> u8 {
        let Some(content_height) = self.last_content_height else {
            return 100;
        };
        let Some(total_height) = self.last_rendered_height else {
            return 100;
        };
        if total_height == 0 {
            return 100;
        }
        let max_scroll = total_height.saturating_sub(content_height);
        if max_scroll == 0 {
            100
        } else {
            (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
                as u8
        }
    }

    #[cfg(test)]
    pub(crate) fn scroll_offset(&self) -> usize {
        self.scroll_offset
    }

    #[cfg(test)]
    pub(crate) fn set_scroll_offset(&mut self, scroll_offset: usize) {
        self.scroll_offset = scroll_offset;
        self.stick_to_bottom = scroll_offset == usize::MAX;
        self.user_scrolled_during_stream = scroll_offset != usize::MAX;
    }

    #[cfg(test)]
    pub(crate) fn content_area(&self, area: Rect) -> Rect {
        let mut area = area;
        area.y = area.y.saturating_add(1);
        area.height = area.height.saturating_sub(2);
        area
    }

    #[cfg(test)]
    pub(crate) fn page_height_for_area(&self, area: Rect) -> usize {
        self.last_content_height
            .unwrap_or_else(|| self.content_area(area).height as usize)
    }

    fn render_area(
        &mut self,
        area: Rect,
        buf: &mut Buffer,
        fill_empty_rows: bool,
        bottom_align_if_short: bool,
    ) {
        let width = area.width.max(1);
        let follow_bottom = self.is_scrolled_to_bottom();
        if follow_bottom {
            self.stick_to_bottom = true;
        } else if self.last_width != Some(width)
            && let Some(last_width) = self.last_width
            && let Some(anchor) = self.anchor_for_offset(last_width)
        {
            self.scroll_offset = self.offset_for_anchor(anchor, width);
        }

        self.last_width = Some(width);
        self.last_content_height = Some(area.height as usize);

        let content_height = self.content_height(width);
        self.last_rendered_height = Some(content_height);
        if self.stick_to_bottom {
            self.scroll_offset = content_height.saturating_sub(area.height as usize);
        }
        let mut moved_for_highlight = false;
        if let Some(idx) = self.pending_scroll_chunk.take() {
            let before = self.scroll_offset;
            self.ensure_chunk_visible(idx, area, width);
            moved_for_highlight = self.scroll_offset != before;
        }
        let max_scroll = content_height.saturating_sub(area.height as usize);
        self.scroll_offset = self.scroll_offset.min(max_scroll);
        if moved_for_highlight && self.scroll_offset < max_scroll {
            self.stick_to_bottom = false;
            self.user_scrolled_during_stream = true;
        } else if max_scroll > 0
            && self.scroll_offset >= max_scroll
            && !self.user_scrolled_during_stream
        {
            self.stick_to_bottom = true;
            self.user_scrolled_during_stream = false;
        }

        Clear.render(area, buf);

        let mut y = -(self.scroll_offset as isize);
        if bottom_align_if_short && content_height < area.height as usize {
            y += area.height.saturating_sub(content_height as u16) as isize;
        }

        let mut drawn_bottom = area.y;
        for renderable in &self.renderables {
            let top = y;
            let height = renderable.desired_height(width) as isize;
            y += height;
            let bottom = y;
            if bottom <= 0 {
                continue;
            }
            if top >= area.height as isize {
                break;
            }
            if top < 0 {
                let drawn = render_offset_content(area, buf, &**renderable, (-top) as u16);
                drawn_bottom = drawn_bottom.max(area.y + drawn);
            } else {
                let draw_height = (height as u16).min(area.height.saturating_sub(top as u16));
                let draw_area = Rect::new(area.x, area.y + top as u16, area.width, draw_height);
                renderable.render(draw_area, buf);
                drawn_bottom = drawn_bottom.max(draw_area.y.saturating_add(draw_area.height));
            }
        }

        if fill_empty_rows {
            for y in drawn_bottom..area.bottom() {
                if area.width == 0 {
                    break;
                }
                buf[(area.x, y)] = Cell::from('~');
                for x in area.x + 1..area.right() {
                    buf[(x, y)] = Cell::from(' ');
                }
            }
        }

        self.last_area = Some(area);
        self.last_top_line = self.scroll_offset;
        self.last_total_lines = content_height;
        self.last_padding_top = if bottom_align_if_short && content_height < area.height as usize {
            area.height.saturating_sub(content_height as u16) as usize
        } else {
            0
        };
        self.apply_selection_highlight(area, buf);
    }

    fn apply_selection_highlight(&self, area: Rect, buf: &mut Buffer) {
        let Some((start, end)) = self.selection.ordered_endpoints() else {
            return;
        };
        if area.width == 0 || area.height == 0 {
            return;
        }

        let selection_style = transcript_selection_style();
        for y in area.y..area.bottom() {
            let visible_row = y.saturating_sub(area.y) as usize;
            if visible_row < self.last_padding_top {
                continue;
            }
            let line_index = self
                .last_top_line
                .saturating_add(visible_row.saturating_sub(self.last_padding_top));
            if line_index < start.line_index || line_index > end.line_index {
                continue;
            }
            let (col_start, col_end) = if start.line_index == end.line_index {
                (start.column.min(end.column), end.column.max(start.column))
            } else if line_index == start.line_index {
                (start.column, area.width as usize)
            } else if line_index == end.line_index {
                (0, end.column)
            } else {
                (0, area.width as usize)
            };
            if col_start == col_end {
                continue;
            }
            let x_start = area.x.saturating_add((col_start as u16).min(area.width));
            let x_end = area.x.saturating_add((col_end as u16).min(area.width));
            for x in x_start..x_end {
                let style = buf[(x, y)].style().patch(selection_style);
                buf[(x, y)].set_style(style);
            }
        }
    }

    fn last_area_contains(&self, column: u16, row: u16) -> bool {
        self.last_area.is_some_and(|area| {
            column >= area.x && column < area.right() && row >= area.y && row < area.bottom()
        })
    }

    fn selection_point_from_position(
        &self,
        column: u16,
        row: u16,
    ) -> Option<TranscriptSelectionPoint> {
        let area = self.last_area?;
        if column < area.x || column >= area.right() || row < area.y || row >= area.bottom() {
            return None;
        }
        if self.last_total_lines == 0 {
            return None;
        }

        let row = row.saturating_sub(area.y) as usize;
        if row < self.last_padding_top {
            return None;
        }
        let line_index = self
            .last_top_line
            .saturating_add(row.saturating_sub(self.last_padding_top))
            .min(self.last_total_lines.saturating_sub(1));
        let column = column.saturating_sub(area.x) as usize;
        Some(TranscriptSelectionPoint { line_index, column })
    }

    fn drag_selection_point_from_position(
        &self,
        column: u16,
        row: u16,
    ) -> Option<(TranscriptSelectionPoint, Option<DragAutoScroll>)> {
        let area = self.last_area?;
        if area.is_empty() || self.last_total_lines == 0 {
            return None;
        }

        let clamped_column = clamp_to_area_column(area, column);
        if row < area.y {
            let point = self.edge_selection_point(area, ScrollDirection::Up, clamped_column)?;
            return Some((
                point,
                Some(DragAutoScroll {
                    column: clamped_column,
                    direction: ScrollDirection::Up,
                    lines_per_tick: DRAG_AUTOSCROLL_LINES_PER_TICK,
                }),
            ));
        }
        if row >= area.bottom() {
            let point = self.edge_selection_point(area, ScrollDirection::Down, clamped_column)?;
            return Some((
                point,
                Some(DragAutoScroll {
                    column: clamped_column,
                    direction: ScrollDirection::Down,
                    lines_per_tick: DRAG_AUTOSCROLL_LINES_PER_TICK,
                }),
            ));
        }

        let row_offset = row.saturating_sub(area.y);
        if (row_offset as usize) < self.last_padding_top {
            return None;
        }

        let point = self.selection_point_from_position(clamped_column, row)?;
        let autoscroll = if row_offset < DRAG_AUTOSCROLL_EDGE_ROWS {
            Some(DragAutoScroll {
                column: clamped_column,
                direction: ScrollDirection::Up,
                lines_per_tick: DRAG_AUTOSCROLL_LINES_PER_TICK,
            })
        } else if area.height.saturating_sub(row_offset).saturating_sub(1)
            < DRAG_AUTOSCROLL_EDGE_ROWS
        {
            Some(DragAutoScroll {
                column: clamped_column,
                direction: ScrollDirection::Down,
                lines_per_tick: DRAG_AUTOSCROLL_LINES_PER_TICK,
            })
        } else {
            None
        };
        Some((point, autoscroll))
    }

    fn edge_selection_point(
        &self,
        area: Rect,
        direction: ScrollDirection,
        column: u16,
    ) -> Option<TranscriptSelectionPoint> {
        self.edge_selection_point_for_top(area, direction, column, self.last_top_line)
    }

    fn edge_selection_point_for_top(
        &self,
        area: Rect,
        direction: ScrollDirection,
        column: u16,
        top_line: usize,
    ) -> Option<TranscriptSelectionPoint> {
        if area.is_empty() || self.last_total_lines == 0 {
            return None;
        }

        let visible_rows = area.height as usize;
        if visible_rows <= self.last_padding_top {
            return None;
        }
        let content_rows = visible_rows.saturating_sub(self.last_padding_top);
        let row = match direction {
            ScrollDirection::Up => self.last_padding_top,
            ScrollDirection::Down => self
                .last_padding_top
                .saturating_add(content_rows.saturating_sub(1)),
        };
        let line_index = top_line
            .saturating_add(row.saturating_sub(self.last_padding_top))
            .min(self.last_total_lines.saturating_sub(1));
        let column = clamp_to_area_column(area, column).saturating_sub(area.x) as usize;
        Some(TranscriptSelectionPoint { line_index, column })
    }

    fn selection_to_text(&self) -> Option<String> {
        let (start, end) = self.selection.ordered_endpoints()?;
        let width = self.last_area?.width.max(1);
        let lines = self.visual_plain_lines_for_width(width);
        if lines.is_empty() {
            return None;
        }

        let end_index = end.line_index.min(lines.len().saturating_sub(1));
        let start_index = start.line_index.min(end_index);
        let mut selected_lines = Vec::new();
        for (line_index, line_text) in lines
            .iter()
            .enumerate()
            .take(end_index + 1)
            .skip(start_index)
        {
            let line_width = line_text.display_width;
            let (col_start, col_end) = if start_index == end_index {
                (start.column.min(end.column), end.column.max(start.column))
            } else if line_index == start_index {
                (start.column, line_width)
            } else if line_index == end_index {
                (0, end.column)
            } else {
                (0, line_width)
            };
            selected_lines.push(slice_display_columns(&line_text.plain, col_start, col_end));
        }
        Some(selected_lines.join("\n"))
    }

    fn visual_plain_lines_for_width(&self, width: u16) -> Vec<TranscriptVisualLine> {
        let width = width.max(1);
        let mut lines = Vec::new();
        let mut has_visible_prior_cell = false;
        let mut prior_force_separator_after = false;
        for cell in &self.cells {
            let cell_lines = self.mode.lines(cell.as_ref(), width);
            let desired_height = self.mode.desired_height(cell.as_ref(), width) as usize;
            if cell_lines.is_empty() && desired_height == 0 {
                continue;
            }
            if has_visible_prior_cell && !cell.is_stream_continuation() {
                push_cell_separator(&mut lines, prior_force_separator_after);
            }
            push_visual_lines(&mut lines, cell_lines, width, desired_height);
            has_visible_prior_cell = true;
            prior_force_separator_after = cell.force_transcript_separator_after();
        }

        if let Some(key) = self.live_tail_key
            && !self.live_tail_lines.is_empty()
        {
            if has_visible_prior_cell && !key.is_stream_continuation {
                push_cell_separator(&mut lines, prior_force_separator_after);
            }
            push_visual_lines(&mut lines, self.live_tail_lines.clone(), width, 0);
        }
        lines
    }

    #[cfg(test)]
    fn semantic_plain_lines_for_width(&self, width: u16) -> Vec<String> {
        self.visual_plain_lines_for_width(width)
            .into_iter()
            .map(|line| line.plain)
            .collect()
    }

    fn update_after_upward_scroll(&mut self, old_offset: usize, was_at_bottom: bool) {
        if self.scroll_offset != old_offset {
            self.stick_to_bottom = false;
            self.user_scrolled_during_stream = true;
        } else if was_at_bottom {
            self.stick_to_bottom = true;
            self.user_scrolled_during_stream = false;
        }
    }

    fn render_cells(
        cells: &[Arc<dyn HistoryCell>],
        highlight_cell: Option<usize>,
        mode: TranscriptRenderMode,
    ) -> (TranscriptRenderableList, CellLayoutList) {
        let mut prior_visible_layout = None;
        let mut layouts = Vec::with_capacity(cells.len());
        let renderables = cells
            .iter()
            .enumerate()
            .map(|(idx, cell)| {
                let layout = Rc::new(RefCell::new(CellLayoutInfo::default()));
                let renderable = Self::render_cell(
                    idx,
                    cell.clone(),
                    prior_visible_layout.clone(),
                    highlight_cell,
                    mode,
                    layout.clone(),
                );
                let is_visible = mode.is_visible(cell.as_ref(), u16::MAX);
                if is_visible {
                    prior_visible_layout = Some(layout.clone());
                }
                layouts.push(layout);
                renderable
            })
            .collect();
        (renderables, layouts)
    }

    fn render_cell(
        idx: usize,
        cell: Arc<dyn HistoryCell>,
        prior_visible_layout: Option<CellLayoutRef>,
        highlight_cell: Option<usize>,
        mode: TranscriptRenderMode,
        layout: CellLayoutRef,
    ) -> Box<dyn Renderable> {
        let style = if cell.as_any().is::<UserHistoryCell>() {
            if highlight_cell == Some(idx) {
                user_message_style().reversed()
            } else {
                user_message_style()
            }
        } else {
            Style::default()
        };

        Box::new(CachedRenderable::new(CellRenderable {
            cell,
            mode,
            prior_visible_layout,
            layout,
            style,
            cache: std::cell::RefCell::new(None),
        }))
    }

    fn rebuild_renderables(&mut self) {
        let tail_renderable = self.take_live_tail_renderable();
        let (renderables, cell_layouts) =
            Self::render_cells(&self.cells, self.highlight_cell, self.mode);
        self.renderables = renderables;
        self.cell_layouts = cell_layouts;
        if let Some(spacing) = &self.live_tail_spacing {
            spacing.borrow_mut().prior_visible_layout =
                self.prior_visible_committed_layout(u16::MAX);
        }
        if let Some(tail) = tail_renderable {
            self.renderables.push(tail);
        }
    }

    fn take_live_tail_renderable(&mut self) -> Option<Box<dyn Renderable>> {
        (self.renderables.len() > self.cells.len()).then(|| self.renderables.pop())?
    }

    fn prior_visible_committed_layout(&self, width: u16) -> Option<CellLayoutRef> {
        self.cells
            .iter()
            .zip(&self.cell_layouts)
            .rev()
            .find(|(cell, _)| self.mode.is_visible(cell.as_ref(), width))
            .map(|(_, layout)| layout.clone())
    }

    pub(crate) fn live_tail_from_lines(lines: Vec<Line<'static>>) -> Option<TranscriptLiveTail> {
        Self::live_tail_from_lines_with_backgrounds(lines, false)
    }

    pub(crate) fn live_tail_from_lines_with_backgrounds(
        lines: Vec<Line<'static>>,
        fill_line_backgrounds: bool,
    ) -> Option<TranscriptLiveTail> {
        if lines.is_empty() {
            return None;
        }
        let renderable = Box::new(CachedRenderable::new(LiveTailRenderable {
            lines: lines.clone(),
            fill_line_backgrounds,
        }));
        Some(TranscriptLiveTail { lines, renderable })
    }

    fn content_height(&self, width: u16) -> usize {
        self.renderables
            .iter()
            .map(|renderable| renderable.desired_height(width) as usize)
            .sum()
    }

    fn page_height(&self) -> usize {
        self.last_content_height.unwrap_or(1)
    }

    fn ensure_chunk_visible(&mut self, idx: usize, area: Rect, width: u16) {
        if area.height == 0 || idx >= self.renderables.len() {
            return;
        }

        let first = self
            .renderables
            .iter()
            .take(idx)
            .map(|renderable| renderable.desired_height(width) as usize)
            .sum::<usize>();
        let last = first + self.renderables[idx].desired_height(width) as usize;
        let current_top = self.scroll_offset;
        let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
        if first < current_top {
            self.scroll_offset = first;
        } else if last > current_bottom {
            self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
        }
    }

    fn anchor_for_offset(&self, width: u16) -> Option<ScrollAnchor> {
        if self.renderables.is_empty() {
            return None;
        }

        let mut start = 0usize;
        for (chunk_index, renderable) in self.renderables.iter().enumerate() {
            let height = renderable.desired_height(width) as usize;
            let end = start + height;
            if self.scroll_offset < end {
                return Some(ScrollAnchor {
                    chunk_index,
                    intra_chunk_row: self.scroll_offset.saturating_sub(start),
                });
            }
            start = end;
        }

        self.renderables.last().map(|renderable| ScrollAnchor {
            chunk_index: self.renderables.len().saturating_sub(1),
            intra_chunk_row: renderable.desired_height(width).saturating_sub(1) as usize,
        })
    }

    fn offset_for_anchor(&self, anchor: ScrollAnchor, width: u16) -> usize {
        if self.renderables.is_empty() {
            return 0;
        }

        let chunk_index = anchor
            .chunk_index
            .min(self.renderables.len().saturating_sub(1));
        let top = self
            .renderables
            .iter()
            .take(chunk_index)
            .map(|renderable| renderable.desired_height(width) as usize)
            .sum::<usize>();
        let chunk_height = self.renderables[chunk_index].desired_height(width) as usize;
        top + anchor.intra_chunk_row.min(chunk_height.saturating_sub(1))
    }
}

struct CachedRenderable {
    renderable: Box<dyn Renderable>,
    height: std::cell::Cell<Option<u16>>,
    last_width: std::cell::Cell<Option<u16>>,
}

impl CachedRenderable {
    fn new(renderable: impl Into<Box<dyn Renderable>>) -> Self {
        Self {
            renderable: renderable.into(),
            height: std::cell::Cell::new(None),
            last_width: std::cell::Cell::new(None),
        }
    }
}

impl Renderable for CachedRenderable {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        self.renderable.render(area, buf);
    }

    fn desired_height(&self, width: u16) -> u16 {
        if self.last_width.get() != Some(width) {
            let height = self.renderable.desired_height(width);
            self.height.set(Some(height));
            self.last_width.set(Some(width));
        }
        self.height.get().unwrap_or(0)
    }
}

struct LiveTailRenderable {
    lines: Vec<Line<'static>>,
    fill_line_backgrounds: bool,
}

impl Renderable for LiveTailRenderable {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        if self.fill_line_backgrounds {
            render_line_backgrounds(&self.lines, area, buf, 0);
        }
        Paragraph::new(Text::from(self.lines.clone()))
            .wrap(Wrap { trim: false })
            .render(area, buf);
    }

    fn desired_height(&self, width: u16) -> u16 {
        rendered_lines_height(&self.lines, width)
    }
}

#[derive(Clone, Copy, Debug, Default)]
struct CellLayoutInfo {
    width: Option<u16>,
    should_insert_separator_after: bool,
    force_separator_after: bool,
}

struct ConditionalTopGapRenderable {
    renderable: Box<dyn Renderable>,
    spacing: Rc<RefCell<TranscriptLiveTailSpacing>>,
}

impl ConditionalTopGapRenderable {
    fn new(
        renderable: Box<dyn Renderable>,
        spacing: Rc<RefCell<TranscriptLiveTailSpacing>>,
    ) -> Self {
        Self {
            renderable,
            spacing,
        }
    }

    fn top_gap(&self, width: u16) -> bool {
        let spacing = self.spacing.borrow();
        !spacing.is_stream_continuation
            && spacing.prior_visible_layout.as_ref().is_some_and(|layout| {
                let layout = layout.borrow();
                layout.width == Some(width) && layout_wants_separator_after(*layout)
            })
    }
}

impl Renderable for ConditionalTopGapRenderable {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        let area = if self.top_gap(area.width) {
            Rect::new(
                area.x,
                area.y.saturating_add(1),
                area.width,
                area.height.saturating_sub(1),
            )
        } else {
            area
        };
        self.renderable.render(area, buf);
    }

    fn desired_height(&self, width: u16) -> u16 {
        self.renderable
            .desired_height(width)
            .saturating_add(u16::from(self.top_gap(width)))
    }
}

#[derive(Debug)]
struct CellRenderable {
    cell: Arc<dyn HistoryCell>,
    mode: TranscriptRenderMode,
    prior_visible_layout: Option<CellLayoutRef>,
    layout: CellLayoutRef,
    style: Style,
    cache: std::cell::RefCell<Option<CellRenderCache>>,
}

#[derive(Clone, Debug)]
struct CellRenderState {
    lines: Vec<Line<'static>>,
    height: u16,
    top_gap: bool,
}

#[derive(Clone, Debug)]
struct CellRenderCache {
    width: u16,
    palette_version: u64,
    state: CellRenderState,
}

impl CellRenderable {
    fn render_state(&self, width: u16) -> CellRenderState {
        let palette_version = terminal_palette::palette_version();
        if let Some(cache) = self.cache.borrow().as_ref()
            && cache.width == width
            && cache.palette_version == palette_version
        {
            return cache.state.clone();
        }

        let lines = self.mode.lines(self.cell.as_ref(), width);
        let height = rendered_lines_height(&lines, width)
            .max(self.mode.desired_height(self.cell.as_ref(), width));
        let should_insert_separator_after =
            should_insert_separator_after_rendered_lines(&lines, width, height as usize);
        self.layout.replace(CellLayoutInfo {
            width: Some(width),
            should_insert_separator_after,
            force_separator_after: self.cell.force_transcript_separator_after(),
        });
        let top_gap = height > 0
            && !self.cell.is_stream_continuation()
            && self.prior_visible_layout.as_ref().is_some_and(|layout| {
                let layout = layout.borrow();
                layout.width == Some(width) && layout_wants_separator_after(*layout)
            });
        let state = CellRenderState {
            lines,
            height,
            top_gap,
        };
        self.cache.replace(Some(CellRenderCache {
            width,
            palette_version,
            state: state.clone(),
        }));
        state
    }
}

impl Renderable for CellRenderable {
    fn render(&self, area: Rect, buf: &mut Buffer) {
        let state = self.render_state(area.width);
        if state.height == 0 {
            return;
        }
        let area = if state.top_gap {
            Rect::new(
                area.x,
                area.y.saturating_add(1),
                area.width,
                area.height.saturating_sub(1),
            )
        } else {
            area
        };
        let style = self
            .cell
            .block_style()
            .unwrap_or_default()
            .patch(self.style);
        Block::default().style(style).render(area, buf);
        if self.cell.fill_line_backgrounds() {
            render_line_backgrounds(&state.lines, area, buf, 0);
        }
        Paragraph::new(Text::from(state.lines))
            .style(style)
            .wrap(Wrap { trim: false })
            .render(area, buf);
    }

    fn desired_height(&self, width: u16) -> u16 {
        let state = self.render_state(width);
        if state.height == 0 {
            0
        } else {
            state.height.saturating_add(u16::from(state.top_gap))
        }
    }
}

impl TranscriptRenderMode {
    fn lines(self, cell: &dyn HistoryCell, width: u16) -> Vec<Line<'static>> {
        match self {
            TranscriptRenderMode::Display => cell.display_lines(width),
            TranscriptRenderMode::Transcript => cell.transcript_lines(width),
        }
    }

    fn desired_height(self, cell: &dyn HistoryCell, width: u16) -> u16 {
        match self {
            TranscriptRenderMode::Display => cell.desired_height(width),
            TranscriptRenderMode::Transcript => cell.desired_transcript_height(width),
        }
    }

    fn is_visible(self, cell: &dyn HistoryCell, width: u16) -> bool {
        self.desired_height(cell, width) > 0
    }
}

fn layout_wants_separator_after(layout: CellLayoutInfo) -> bool {
    layout.should_insert_separator_after || layout.force_separator_after
}

fn push_cell_separator(lines: &mut Vec<TranscriptVisualLine>, force: bool) {
    if force || should_insert_separator_after_visual_lines(lines) {
        lines.push(TranscriptVisualLine::blank());
    }
}

fn should_insert_separator_after_rendered_lines(
    rendered_lines: &[Line<'static>],
    width: u16,
    height: usize,
) -> bool {
    let mut lines = Vec::new();
    push_visual_lines(&mut lines, rendered_lines.to_vec(), width, height);
    should_insert_separator_after_visual_lines(&lines)
}

fn should_insert_separator_after_visual_lines(lines: &[TranscriptVisualLine]) -> bool {
    !lines.last().is_some_and(TranscriptVisualLine::is_blank)
}

fn rendered_lines_height(lines: &[Line<'static>], width: u16) -> u16 {
    if lines.is_empty() {
        return 0;
    }
    if let [line] = lines
        && line
            .spans
            .iter()
            .all(|span| span.content.chars().all(char::is_whitespace))
    {
        return 1;
    }

    Paragraph::new(Text::from(lines.to_vec()))
        .wrap(Wrap { trim: false })
        .line_count(width)
        .try_into()
        .unwrap_or(u16::MAX)
}

fn render_offset_content(
    area: Rect,
    buf: &mut Buffer,
    renderable: &dyn Renderable,
    scroll_offset: u16,
) -> u16 {
    let height = renderable.desired_height(area.width);
    let mut tall_buf = Buffer::empty(Rect::new(
        0,
        0,
        area.width,
        height.min(area.height + scroll_offset),
    ));
    renderable.render(*tall_buf.area(), &mut tall_buf);
    let copy_height = area
        .height
        .min(tall_buf.area().height.saturating_sub(scroll_offset));
    for y in 0..copy_height {
        let src_y = y + scroll_offset;
        for x in 0..area.width {
            buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone();
        }
    }

    copy_height
}

fn clamp_to_area_column(area: Rect, column: u16) -> u16 {
    if area.width == 0 {
        return area.x;
    }
    column.clamp(area.x, area.right().saturating_sub(1))
}

fn push_visual_lines<I>(
    out: &mut Vec<TranscriptVisualLine>,
    lines: I,
    width: u16,
    min_height: usize,
) where
    I: IntoIterator<Item = Line<'static>>,
{
    let start_len = out.len();
    let lines = lines.into_iter().collect::<Vec<_>>();
    let height = rendered_lines_height(&lines, width).max(min_height as u16);
    if height > 0 {
        let area = Rect::new(0, 0, width.max(1), height);
        let mut buf = Buffer::empty(area);
        Paragraph::new(Text::from(lines))
            .wrap(Wrap { trim: false })
            .render(area, &mut buf);
        out.extend((0..height).map(|y| buffer_row_plain_text(&buf, width, y)));
    }
    let inserted = out.len().saturating_sub(start_len);
    for _ in inserted..min_height {
        out.push(TranscriptVisualLine::blank());
    }
}

fn buffer_row_plain_text(buf: &Buffer, width: u16, y: u16) -> TranscriptVisualLine {
    let mut plain = String::new();
    let mut cells_to_skip = 0usize;
    for x in 0..width {
        if cells_to_skip > 0 {
            cells_to_skip -= 1;
            continue;
        }
        let symbol = buf[(x, y)].symbol();
        let symbol_width = UnicodeWidthStr::width(symbol);
        if symbol_width == 0 {
            continue;
        }
        plain.push_str(symbol);
        cells_to_skip = symbol_width.saturating_sub(1);
    }
    plain.truncate(plain.trim_end_matches(' ').len());
    TranscriptVisualLine::from_plain(plain)
}

fn slice_display_columns(text: &str, col_start: usize, col_end: usize) -> String {
    if col_start >= col_end {
        return String::new();
    }

    let mut out = String::new();
    let mut col = 0usize;
    for ch in text.chars() {
        let width = UnicodeWidthChar::width(ch).unwrap_or(0);
        let next_col = col.saturating_add(width);
        if col >= col_start && col < col_end {
            out.push(ch);
        }
        col = next_col;
        if col >= col_end {
            break;
        }
    }
    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::KeyModifiers;
    use crossterm::event::MouseEvent;
    use crossterm::event::MouseEventKind;
    use pretty_assertions::assert_eq;
    use ratatui::buffer::Buffer;
    use ratatui::layout::Rect;
    use ratatui::style::Color;
    use ratatui::style::Stylize;

    #[derive(Debug)]
    struct TestCell(&'static str);

    #[derive(Debug)]
    struct OwnedTestCell(String);

    #[derive(Debug)]
    struct FixedHeightCell(usize);

    #[derive(Debug)]
    struct MultiLineTestCell(Vec<&'static str>);

    #[derive(Debug)]
    struct StyledBlockCell;

    #[derive(Debug)]
    struct SplitRenderCell;

    #[derive(Debug)]
    struct HiddenDisplayCell;

    #[derive(Debug)]
    struct CountingDisplayCell {
        calls: Arc<std::sync::atomic::AtomicUsize>,
    }

    impl HistoryCell for TestCell {
        fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
            textwrap::wrap(self.0, usize::from(width.max(1)))
                .into_iter()
                .map(|line| line.to_string().into())
                .collect()
        }
    }

    impl HistoryCell for OwnedTestCell {
        fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
            textwrap::wrap(&self.0, usize::from(width.max(1)))
                .into_iter()
                .map(|line| line.to_string().into())
                .collect()
        }
    }

    impl HistoryCell for FixedHeightCell {
        fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
            (0..self.0).map(|_| "x".into()).collect()
        }
    }

    impl HistoryCell for MultiLineTestCell {
        fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
            self.0.iter().map(|line| (*line).into()).collect()
        }
    }

    impl HistoryCell for StyledBlockCell {
        fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
            vec!["styled".into()]
        }

        fn block_style(&self) -> Option<Style> {
            Some(Style::default().bg(Color::Blue))
        }
    }

    impl HistoryCell for SplitRenderCell {
        fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
            vec!["display".into()]
        }

        fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
            vec!["transcript".into()]
        }
    }

    impl HistoryCell for HiddenDisplayCell {
        fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
            Vec::new()
        }

        fn desired_height(&self, _width: u16) -> u16 {
            0
        }

        fn transcript_lines(&self, _width: u16) -> Vec<Line<'static>> {
            vec!["hidden transcript".into()]
        }
    }

    impl HistoryCell for CountingDisplayCell {
        fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
            self.calls
                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
            vec!["counted".into()]
        }

        fn desired_height(&self, _width: u16) -> u16 {
            1
        }
    }

    #[derive(Debug)]
    struct ContinuationCell(Vec<&'static str>);

    impl HistoryCell for ContinuationCell {
        fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
            self.0.iter().map(|line| (*line).into()).collect()
        }

        fn is_stream_continuation(&self) -> bool {
            true
        }
    }

    fn area_lines(buf: &Buffer, area: Rect) -> Vec<String> {
        (area.y..area.bottom())
            .map(|y| {
                (area.x..area.right())
                    .map(|x| buf[(x, y)].symbol().chars().next().unwrap_or(' '))
                    .collect::<String>()
            })
            .collect()
    }

    fn assert_row_bg_is_not(buf: &Buffer, width: u16, y: u16, bg: Option<Color>) {
        for x in 0..width {
            assert_ne!(
                buf[(x, y)].style().bg,
                bg,
                "did not expect background {bg:?} at x={x}, y={y}"
            );
        }
    }

    fn assert_row_bg_is(buf: &Buffer, width: u16, y: u16, bg: Option<Color>) {
        for x in 0..width {
            assert_eq!(
                buf[(x, y)].style().bg,
                bg,
                "expected background {bg:?} at x={x}, y={y}"
            );
        }
    }

    fn render_test_view(view: &mut TranscriptView, width: u16, height: u16) -> Buffer {
        let area = Rect::new(0, 0, width, height);
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        buf
    }

    fn mouse(kind: MouseEventKind, column: u16, row: u16) -> MouseEvent {
        MouseEvent {
            kind,
            column,
            row,
            modifiers: KeyModifiers::NONE,
        }
    }

    fn select_columns(
        view: &mut TranscriptView,
        start_line: usize,
        start_column: usize,
        end_line: usize,
        end_column: usize,
    ) -> Option<String> {
        view.selection.anchor = Some(TranscriptSelectionPoint {
            line_index: start_line,
            column: start_column,
        });
        view.selection.head = Some(TranscriptSelectionPoint {
            line_index: end_line,
            column: end_column,
        });
        view.selection_to_text()
    }

    fn content_line_for_rendered_row(view: &TranscriptView, rendered_row: usize) -> usize {
        view.last_top_line
            .saturating_add(rendered_row.saturating_sub(view.last_padding_top))
    }

    fn counting_display_view() -> (TranscriptView, Arc<std::sync::atomic::AtomicUsize>) {
        let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
        let view = TranscriptView::new(
            vec![Arc::new(CountingDisplayCell {
                calls: calls.clone(),
            })],
            TranscriptRenderMode::Display,
        );
        calls.store(0, std::sync::atomic::Ordering::Relaxed);
        (view, calls)
    }

    #[test]
    fn render_reuses_display_lines_within_frame() {
        let (mut view, calls) = counting_display_view();

        let _ = render_test_view(&mut view, 20, 3);

        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
    }

    #[test]
    fn render_reuses_display_lines_across_frames() {
        let (mut view, calls) = counting_display_view();

        let _ = render_test_view(&mut view, 20, 3);
        let _ = render_test_view(&mut view, 20, 3);

        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
    }

    #[test]
    fn render_cache_invalidates_on_width_change() {
        let (mut view, calls) = counting_display_view();

        let _ = render_test_view(&mut view, 20, 3);
        let _ = render_test_view(&mut view, 30, 3);

        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 2);
    }

    #[test]
    fn desired_height_and_render_share_cached_lines() {
        let (mut view, calls) = counting_display_view();

        assert_eq!(view.desired_height(20), 1);
        let _ = render_test_view(&mut view, 20, 3);

        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
    }

    #[test]
    fn insert_cell_preserves_existing_render_cache() {
        let (mut view, calls) = counting_display_view();

        let _ = render_test_view(&mut view, 20, 3);
        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);

        view.insert_cell(Arc::new(TestCell("second")));
        let _ = render_test_view(&mut view, 20, 5);

        assert_eq!(calls.load(std::sync::atomic::Ordering::Relaxed), 1);
    }

    #[test]
    fn display_mode_renders_display_lines() {
        let mut view = TranscriptView::new(
            vec![Arc::new(SplitRenderCell)],
            TranscriptRenderMode::Display,
        );
        let buf = render_test_view(&mut view, 20, 3);

        let rendered = area_lines(&buf, Rect::new(0, 0, 20, 3)).join("\n");
        assert!(rendered.contains("display"));
        assert!(!rendered.contains("transcript"));
    }

    #[test]
    fn transcript_mode_renders_transcript_lines() {
        let mut view = TranscriptView::new(
            vec![Arc::new(SplitRenderCell)],
            TranscriptRenderMode::Transcript,
        );
        let buf = render_test_view(&mut view, 20, 3);

        let rendered = area_lines(&buf, Rect::new(0, 0, 20, 3)).join("\n");
        assert!(rendered.contains("transcript"));
        assert!(!rendered.contains("display"));
    }

    #[test]
    fn display_mode_skips_display_empty_cells_without_extra_gap() {
        let mut view = TranscriptView::new(
            vec![
                Arc::new(TestCell("first")) as Arc<dyn HistoryCell>,
                Arc::new(HiddenDisplayCell) as Arc<dyn HistoryCell>,
                Arc::new(TestCell("second")) as Arc<dyn HistoryCell>,
            ],
            TranscriptRenderMode::Display,
        );
        let buf = render_test_view(&mut view, 20, 5);

        assert_eq!(view.desired_height(20), 3);
        assert_eq!(
            area_lines(&buf, Rect::new(0, 2, 20, 3)),
            vec![
                "first               ".to_string(),
                "                    ".to_string(),
                "second              ".to_string(),
            ]
        );
        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec!["first".to_string(), String::new(), "second".to_string(),]
        );
    }

    #[test]
    fn display_mode_live_tail_does_not_add_gap_after_only_hidden_cells() {
        let mut view = TranscriptView::new(
            vec![Arc::new(HiddenDisplayCell) as Arc<dyn HistoryCell>],
            TranscriptRenderMode::Display,
        );
        view.sync_live_tail(
            20,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| TranscriptView::live_tail_from_lines(vec!["tail".into()]),
        );
        let buf = render_test_view(&mut view, 20, 2);

        assert_eq!(view.desired_height(20), 1);
        assert_eq!(
            area_lines(&buf, Rect::new(0, 1, 20, 1)),
            vec!["tail                ".to_string()]
        );
    }

    #[test]
    fn display_mode_insert_hidden_cell_before_live_tail_does_not_add_gap() {
        let mut view = TranscriptView::new(Vec::new(), TranscriptRenderMode::Display);
        view.sync_live_tail(
            20,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| TranscriptView::live_tail_from_lines(vec!["tail".into()]),
        );

        view.insert_cell(Arc::new(HiddenDisplayCell));
        let buf = render_test_view(&mut view, 20, 1);

        assert_eq!(view.desired_height(20), 1);
        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 20, 1)),
            vec!["tail                ".to_string()]
        );
    }

    #[test]
    fn display_mode_insert_first_visible_cell_before_live_tail_adds_gap() {
        let mut view = TranscriptView::new(Vec::new(), TranscriptRenderMode::Display);
        view.sync_live_tail(
            20,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| TranscriptView::live_tail_from_lines(vec!["tail".into()]),
        );

        view.insert_cell(Arc::new(TestCell("first")));
        let buf = render_test_view(&mut view, 20, 3);

        assert_eq!(view.desired_height(20), 3);
        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 20, 3)),
            vec![
                "first               ".to_string(),
                "                    ".to_string(),
                "tail                ".to_string(),
            ]
        );
    }

    #[test]
    fn resize_keeps_anchor_when_scrolled_up() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell(
            "alpha beta gamma delta epsilon zeta",
        ))]);
        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 3));
        view.render_inline(Rect::new(0, 0, 8, 3), &mut buf);
        view.apply_scroll(TranscriptScroll::Home);
        view.apply_scroll(TranscriptScroll::Down);

        let mut wide = Buffer::empty(Rect::new(0, 0, 14, 3));
        view.render_inline(Rect::new(0, 0, 14, 3), &mut wide);

        assert_eq!(view.scroll_offset, 0);
    }

    #[test]
    fn mouse_wheel_scrolls_transcript_in_area() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(20))]);
        let _ = render_test_view(&mut view, 10, 5);
        let at_tail = view.scroll_offset();

        let mut scroll = MouseScrollState::default();
        let outcome = view.handle_mouse_event(mouse(MouseEventKind::ScrollUp, 1, 1), &mut scroll);

        assert_eq!(outcome, TranscriptMouseOutcome::Scrolled);
        assert!(view.scroll_offset() < at_tail);
    }

    #[test]
    fn block_style_fills_transcript_cell_width() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(StyledBlockCell)]);
        let buf = render_test_view(&mut view, 12, 3);

        for x in 0..12 {
            assert_eq!(buf[(x, 2)].style().bg, Some(Color::Blue));
        }
    }

    #[test]
    fn default_cells_do_not_gain_block_background() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("plain"))]);
        let buf = render_test_view(&mut view, 12, 3);

        for x in 0..12 {
            assert_ne!(buf[(x, 2)].style().bg, Some(Color::Blue));
        }
    }

    #[test]
    fn mouse_wheel_ignores_sidebar_area() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(20))]);
        let _ = render_test_view(&mut view, 10, 5);
        let before = view.scroll_offset();

        let mut scroll = MouseScrollState::default();
        let outcome = view.handle_mouse_event(mouse(MouseEventKind::ScrollUp, 12, 1), &mut scroll);

        assert_eq!(outcome, TranscriptMouseOutcome::Ignored);
        assert_eq!(view.scroll_offset(), before);
    }

    #[test]
    fn mouse_drag_selection_copies_transcript_only_text() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("alpha beta gamma"))]);
        let _ = render_test_view(&mut view, 20, 3);
        let mut scroll = MouseScrollState::default();

        assert_eq!(
            view.handle_mouse_event(
                mouse(
                    MouseEventKind::Down(crossterm::event::MouseButton::Left),
                    0,
                    2
                ),
                &mut scroll,
            ),
            TranscriptMouseOutcome::SelectionChanged
        );
        assert_eq!(
            view.handle_mouse_event(
                mouse(
                    MouseEventKind::Drag(crossterm::event::MouseButton::Left),
                    5,
                    2
                ),
                &mut scroll,
            ),
            TranscriptMouseOutcome::SelectionChanged
        );
        let outcome = view.handle_mouse_event(
            mouse(
                MouseEventKind::Up(crossterm::event::MouseButton::Left),
                5,
                2,
            ),
            &mut scroll,
        );

        assert_eq!(
            outcome,
            TranscriptMouseOutcome::SelectionCompleted(Some("alpha".to_string()))
        );
    }

    #[test]
    fn selection_copy_preserves_cjk_without_inserted_cell_spaces() {
        let text = "已修复 review 指出的节奏问题";
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell(text))]);
        let _ = render_test_view(&mut view, 40, 3);

        assert_eq!(
            select_columns(&mut view, 0, 0, 0, UnicodeWidthStr::width(text)),
            Some(text.to_string())
        );
    }

    #[test]
    fn selected_text_returns_non_empty_selection_only() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("alpha beta"))]);
        let _ = render_test_view(&mut view, 20, 3);

        view.selection.anchor = Some(TranscriptSelectionPoint {
            line_index: 0,
            column: 0,
        });
        view.selection.head = Some(TranscriptSelectionPoint {
            line_index: 0,
            column: 5,
        });
        assert_eq!(view.selected_text(), Some("alpha".to_string()));

        view.selection.head = Some(TranscriptSelectionPoint {
            line_index: 0,
            column: 0,
        });
        assert_eq!(view.selected_text(), None);
    }

    #[test]
    fn selection_copy_slices_cjk_by_display_columns() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell(
            "已修复 review 指出的节奏问题",
        ))]);
        let _ = render_test_view(&mut view, 40, 3);

        assert_eq!(
            select_columns(&mut view, 0, 0, 0, UnicodeWidthStr::width("已修复")),
            Some("已修复".to_string())
        );
    }

    #[test]
    fn selection_copy_excludes_cjk_char_when_start_is_inside_trailing_cell() {
        let text = "于做一个\"上游语义对齐、局部最小改动\"的修法";
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell(text))]);
        let _ = render_test_view(&mut view, 80, 3);

        assert_eq!(
            select_columns(&mut view, 0, 1, 0, UnicodeWidthStr::width(text)),
            Some("做一个\"上游语义对齐、局部最小改动\"的修法".to_string())
        );
    }

    #[test]
    fn selection_copy_includes_cjk_char_when_leading_cell_is_selected() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("于做"))]);
        let _ = render_test_view(&mut view, 20, 3);

        assert_eq!(
            select_columns(&mut view, 0, 0, 0, 1),
            Some("于".to_string())
        );
    }

    #[test]
    fn selection_copy_cross_line_cjk_ascii_starts_at_selected_column() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(MultiLineTestCell(vec![
            "rollback 后 额外 schedule_frame()，",
            "只会在",
            "autoscroll",
        ]))]);
        let _ = render_test_view(&mut view, 30, 4);

        assert_eq!(
            select_columns(&mut view, 2, 0, 3, UnicodeWidthStr::width("autoscroll"),),
            Some("只会在\nautoscroll".to_string())
        );
    }

    #[test]
    fn selection_copy_uses_visual_wrapped_rows() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(MultiLineTestCell(vec![
            "alpha beta gamma delta epsilon",
        ]))]);
        let _ = render_test_view(&mut view, 10, 3);

        assert_eq!(
            select_columns(&mut view, 1, 0, 1, UnicodeWidthStr::width("gamma")),
            Some("gamma".to_string())
        );
    }

    #[test]
    fn selection_copy_wraps_cjk_visual_rows_without_inserted_spaces() {
        let text = "文档需要更新一下分析ProposedPlan只有文字有背景色";
        let mut view =
            TranscriptView::new_transcript(vec![Arc::new(MultiLineTestCell(vec![text]))]);
        let _ = render_test_view(&mut view, 20, 4);

        assert_eq!(
            select_columns(&mut view, 1, 0, 2, UnicodeWidthStr::width("Plan只有文字")),
            Some("ProposedPlan只有文字\n有背景色".to_string())
        );
    }

    #[test]
    fn live_tail_backgrounds_fill_text_row_when_requested() {
        let Some(plan_bg) = proposed_plan_style().bg else {
            return;
        };
        let width = 40;
        let mut view = TranscriptView::new_transcript(Vec::new());
        view.sync_live_tail(
            width,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| {
                TranscriptView::live_tail_from_lines_with_backgrounds(
                    vec![Line::from("  short plan line").style(proposed_plan_style())],
                    true,
                )
            },
        );
        let height = view.desired_height(width);
        let buf = render_test_view(&mut view, width, height);
        let row = (0..height)
            .find(|y| {
                (0..width)
                    .map(|x| buf[(x, *y)].symbol())
                    .collect::<String>()
                    .contains("short plan line")
            })
            .expect("expected rendered live tail row");

        assert_row_bg_is(&buf, width, row, Some(plan_bg));
    }

    #[test]
    fn live_tail_backgrounds_fill_wrapped_rows_when_requested() {
        let Some(plan_bg) = proposed_plan_style().bg else {
            return;
        };
        let width = 10;
        let mut view = TranscriptView::new_transcript(Vec::new());
        view.sync_live_tail(
            width,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| {
                TranscriptView::live_tail_from_lines_with_backgrounds(
                    vec![Line::from("alpha beta gamma delta").style(proposed_plan_style())],
                    true,
                )
            },
        );
        let height = view.desired_height(width);
        assert!(height > 1, "expected live tail to wrap");
        let buf = render_test_view(&mut view, width, height);

        for y in 0..height {
            assert_row_bg_is(&buf, width, y, Some(plan_bg));
        }
    }

    #[test]
    fn live_tail_from_lines_does_not_fill_backgrounds_by_default() {
        let Some(plan_bg) = proposed_plan_style().bg else {
            return;
        };
        let width = 40;
        let mut view = TranscriptView::new_transcript(Vec::new());
        view.sync_live_tail(
            width,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| {
                TranscriptView::live_tail_from_lines(vec![
                    Line::from("  short").style(proposed_plan_style()),
                ])
            },
        );
        let height = view.desired_height(width);
        let buf = render_test_view(&mut view, width, height);

        assert_ne!(buf[(width - 1, 0)].style().bg, Some(plan_bg));
    }

    #[test]
    fn live_tail_selection_copy_matches_rendered_wrapped_row() {
        let mut view = TranscriptView::new_transcript(Vec::new());
        view.sync_live_tail(
            10,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| {
                TranscriptView::live_tail_from_lines(vec![Line::from(
                    "alpha beta gamma delta epsilon",
                )])
            },
        );
        let buf = render_test_view(&mut view, 10, 4);

        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 10, 4)),
            vec![
                "alpha beta".to_string(),
                "gamma     ".to_string(),
                "delta     ".to_string(),
                "epsilon   ".to_string(),
            ]
        );

        assert_eq!(
            select_columns(&mut view, 1, 0, 1, UnicodeWidthStr::width("gamma")),
            Some("gamma".to_string())
        );
    }

    #[test]
    fn selection_copy_uses_visual_rows_for_proposed_plan() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(new_proposed_plan(
            "alpha beta gamma delta epsilon".to_string(),
        ))]);
        let buf = render_test_view(&mut view, 12, 8);
        let rendered_lines = area_lines(&buf, Rect::new(0, 0, 12, 8));
        let row = rendered_lines
            .iter()
            .position(|line| line.contains("gamma"))
            .expect("rendered gamma row");
        let content_line = content_line_for_rendered_row(&view, row);

        assert_eq!(
            select_columns(
                &mut view,
                content_line,
                2,
                content_line,
                2 + UnicodeWidthStr::width("gamma"),
            ),
            Some("gamma".to_string())
        );
    }

    #[test]
    fn selection_copy_proposed_plan_keeps_single_markdown_blank_lines() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(new_proposed_plan(
            "# Title\n\n## Summary\n\nBody".to_string(),
        ))]);
        let buf = render_test_view(&mut view, 80, 8);
        let rendered_lines = area_lines(&buf, Rect::new(0, 0, 80, 8));
        let body_row = rendered_lines
            .iter()
            .position(|line| line.contains("Body"))
            .expect("rendered body row");
        let body_line = content_line_for_rendered_row(&view, body_row);

        assert_eq!(
            select_columns(&mut view, 1, 0, body_line, UnicodeWidthStr::width("  Body"),),
            Some("\n\n  # Title\n\n  ## Summary\n\n  Body".to_string())
        );
    }

    #[test]
    fn selection_copy_assistant_markdown_keeps_single_blank_lines() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(
            crate::product::tui_app::history_cell::AgentMessageCell::new(
                vec![
                    "# Title".into(),
                    Line::default(),
                    "## Summary".into(),
                    Line::default(),
                    "Body".into(),
                ],
                true,
            ),
        )]);
        let buf = render_test_view(&mut view, 80, 8);
        let rendered_lines = area_lines(&buf, Rect::new(0, 0, 80, 8));
        let body_row = rendered_lines
            .iter()
            .position(|line| line.contains("Body"))
            .expect("rendered body row");
        let body_line = content_line_for_rendered_row(&view, body_row);

        assert_eq!(
            select_columns(&mut view, 0, 0, body_line, UnicodeWidthStr::width("  Body"),),
            Some("• # Title\n\n  ## Summary\n\n  Body".to_string())
        );
    }

    #[test]
    fn cell_separator_does_not_double_existing_blank_line() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(MultiLineTestCell(vec!["first", ""])) as Arc<dyn HistoryCell>,
            Arc::new(TestCell("second")) as Arc<dyn HistoryCell>,
        ]);
        let buf = render_test_view(&mut view, 20, 3);

        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 20, 3)),
            vec![
                "first               ".to_string(),
                "                    ".to_string(),
                "second              ".to_string(),
            ]
        );
        assert_eq!(view.desired_height(20), 3);
        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec!["first".to_string(), String::new(), "second".to_string()]
        );
        assert_eq!(
            select_columns(&mut view, 0, 0, 2, UnicodeWidthStr::width("second")),
            Some("first\n\nsecond".to_string())
        );
    }

    #[test]
    fn user_history_cell_forces_unstyled_separator_after() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(new_user_prompt("hello".to_string(), vec![], vec![])) as Arc<dyn HistoryCell>,
            Arc::new(TestCell("agent reply")) as Arc<dyn HistoryCell>,
        ]);
        let buf = render_test_view(&mut view, 20, 5);

        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 20, 5)),
            vec![
                "                    ".to_string(),
                "› hello             ".to_string(),
                "                    ".to_string(),
                "                    ".to_string(),
                "agent reply         ".to_string(),
            ]
        );
        assert_eq!(view.desired_height(20), 5);
        assert_row_bg_is_not(&buf, 20, 3, user_message_style().bg);
        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec![
                String::new(),
                "› hello".to_string(),
                String::new(),
                String::new(),
                "agent reply".to_string(),
            ]
        );
    }

    #[test]
    fn proposed_plan_forces_unstyled_separator_before_following_user_prompt() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(new_proposed_plan("short plan line".to_string())) as Arc<dyn HistoryCell>,
            Arc::new(new_user_prompt(
                "Implement the plan.".to_string(),
                vec![],
                vec![],
            )) as Arc<dyn HistoryCell>,
        ]);
        let buf = render_test_view(&mut view, 40, 8);
        let rendered_lines = area_lines(&buf, Rect::new(0, 0, 40, 8));
        let implement_row = rendered_lines
            .iter()
            .position(|line| line.contains("Implement the plan."))
            .expect("implement prompt row");
        let separator_row = implement_row - 2;

        assert!(rendered_lines[separator_row].trim().is_empty());
        assert_row_bg_is_not(&buf, 40, separator_row as u16, proposed_plan_style().bg);
        assert_row_bg_is_not(&buf, 40, separator_row as u16, user_message_style().bg);
    }

    #[test]
    fn plan_update_forces_unstyled_separator_before_following_cell() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(new_plan_update(UpdatePlanArgs {
                explanation: None,
                plan: vec![PlanItemArg {
                    step: "Review layout".to_string(),
                    status: StepStatus::InProgress,
                }],
            })) as Arc<dyn HistoryCell>,
            Arc::new(TestCell("agent reply")) as Arc<dyn HistoryCell>,
        ]);
        let buf = render_test_view(&mut view, 40, 6);
        let rendered_lines = area_lines(&buf, Rect::new(0, 0, 40, 6));
        let reply_row = rendered_lines
            .iter()
            .position(|line| line.contains("agent reply"))
            .expect("agent reply row");
        let separator_row = reply_row - 1;

        assert!(rendered_lines[separator_row].trim().is_empty());
        assert_row_bg_is_not(&buf, 40, separator_row as u16, proposed_plan_style().bg);
        assert_eq!(
            view.semantic_plain_lines_for_width(40),
            vec![
                String::new(),
                "• Updated Plan".to_string(),
                "  └ □ Review layout".to_string(),
                String::new(),
                String::new(),
                "agent reply".to_string(),
            ]
        );
    }

    #[test]
    fn stream_continuation_keeps_markdown_blank_line_without_extra_separator() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(MultiLineTestCell(vec!["• first", ""])) as Arc<dyn HistoryCell>,
            Arc::new(ContinuationCell(vec!["  second"])) as Arc<dyn HistoryCell>,
        ]);
        let _ = render_test_view(&mut view, 20, 4);

        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec!["• first".to_string(), String::new(), "  second".to_string()]
        );
    }

    #[test]
    fn proposed_plan_stream_continuation_does_not_insert_forced_separator() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(new_proposed_plan_stream(
                vec![
                    vec!["• ".dim(), "Proposed Plan".bold()].into(),
                    proposed_plan_trailing_body_gap_line(),
                ],
                false,
            )) as Arc<dyn HistoryCell>,
            Arc::new(new_proposed_plan_stream(
                vec![Line::from("  second").style(proposed_plan_style())],
                true,
            )) as Arc<dyn HistoryCell>,
        ]);
        let _ = render_test_view(&mut view, 20, 3);

        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec![
                "• Proposed Plan".to_string(),
                String::new(),
                "  second".to_string(),
            ]
        );
    }

    #[test]
    fn live_tail_separator_does_not_double_existing_blank_line() {
        let mut view =
            TranscriptView::new_transcript(vec![Arc::new(MultiLineTestCell(vec!["first", ""]))]);
        view.sync_live_tail(
            20,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| TranscriptView::live_tail_from_lines(vec!["tail".into()]),
        );
        let buf = render_test_view(&mut view, 20, 3);

        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 20, 3)),
            vec![
                "first               ".to_string(),
                "                    ".to_string(),
                "tail                ".to_string(),
            ]
        );
        assert_eq!(view.desired_height(20), 3);
        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec!["first".to_string(), String::new(), "tail".to_string()]
        );
    }

    #[test]
    fn forced_separator_applies_before_live_tail() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(new_user_prompt(
            "hello".to_string(),
            vec![],
            vec![],
        ))]);
        view.sync_live_tail(
            20,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| TranscriptView::live_tail_from_lines(vec!["tail".into()]),
        );
        let buf = render_test_view(&mut view, 20, 5);

        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 20, 5)),
            vec![
                "                    ".to_string(),
                "› hello             ".to_string(),
                "                    ".to_string(),
                "                    ".to_string(),
                "tail                ".to_string(),
            ]
        );
        assert_eq!(view.desired_height(20), 5);
        assert_row_bg_is_not(&buf, 20, 3, user_message_style().bg);
        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec![
                String::new(),
                "› hello".to_string(),
                String::new(),
                String::new(),
                "tail".to_string(),
            ]
        );
    }

    #[test]
    fn inserted_blank_ended_cell_updates_live_tail_separator() {
        let mut view = TranscriptView::new_transcript(Vec::new());
        view.sync_live_tail(
            20,
            Some(TranscriptLiveTailKey::new(
                TranscriptLiveTailSource::ActiveCell,
                0,
                1,
                false,
                None,
            )),
            |_| TranscriptView::live_tail_from_lines(vec!["tail".into()]),
        );

        view.insert_cell(Arc::new(MultiLineTestCell(vec!["first", ""])));
        let buf = render_test_view(&mut view, 20, 3);

        assert_eq!(
            area_lines(&buf, Rect::new(0, 0, 20, 3)),
            vec![
                "first               ".to_string(),
                "                    ".to_string(),
                "tail                ".to_string(),
            ]
        );
        assert_eq!(view.desired_height(20), 3);
        assert_eq!(
            view.semantic_plain_lines_for_width(20),
            vec!["first".to_string(), String::new(), "tail".to_string()]
        );
    }

    #[test]
    fn selection_copy_matches_rendered_row_after_leading_blank_agent_line() {
        let lines = vec![
            Line::default(),
            Line::from(vec![
                "No discrete regressions were found in the mouse routing, cached layout areas, or "
                    .into(),
                "transcript".underlined(),
                " selection clearing changes. The targeted tests for the new bottom-pane mouse behavior pass."
                    .into(),
            ]),
        ];
        let mut view = TranscriptView::new_transcript(vec![Arc::new(
            crate::product::tui_app::history_cell::AgentMessageCell::new(lines, false),
        )]);
        let buf = render_test_view(&mut view, 90, 6);
        let rendered_lines = area_lines(&buf, Rect::new(0, 0, 90, 6));
        let row = rendered_lines
            .iter()
            .position(|line| line.contains("No discrete regressions"))
            .expect("rendered row");
        let col = rendered_lines[row]
            .find("No discrete regressions")
            .expect("rendered column");
        let content_line = content_line_for_rendered_row(&view, row);

        assert_eq!(
            select_columns(
                &mut view,
                content_line,
                col,
                content_line,
                col + UnicodeWidthStr::width("No discrete"),
            ),
            Some("No discrete".to_string())
        );
    }

    #[test]
    fn selection_copy_preserves_blank_line_between_cells() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(TestCell("first")) as Arc<dyn HistoryCell>,
            Arc::new(TestCell("second")) as Arc<dyn HistoryCell>,
        ]);
        let _ = render_test_view(&mut view, 20, 4);

        assert_eq!(
            select_columns(&mut view, 0, 0, 2, UnicodeWidthStr::width("second")),
            Some("first\n\nsecond".to_string())
        );
    }

    #[test]
    fn drag_below_view_starts_autoscroll() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(20))]);
        let _ = render_test_view(&mut view, 10, 5);
        view.apply_scroll(TranscriptScroll::Home);
        let _ = render_test_view(&mut view, 10, 5);
        let mut scroll = MouseScrollState::default();

        assert_eq!(
            view.handle_mouse_event(
                mouse(
                    MouseEventKind::Down(crossterm::event::MouseButton::Left),
                    1,
                    1
                ),
                &mut scroll,
            ),
            TranscriptMouseOutcome::SelectionChanged
        );
        assert_eq!(
            view.handle_mouse_event(
                mouse(
                    MouseEventKind::Drag(crossterm::event::MouseButton::Left),
                    1,
                    6
                ),
                &mut scroll,
            ),
            TranscriptMouseOutcome::SelectionChanged
        );

        assert!(view.drag_autoscroll_active());
    }

    #[test]
    fn advance_drag_autoscroll_scrolls_and_extends_selection() {
        let area = Rect::new(0, 0, 10, 5);
        let mut view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(20))]);
        view.render_inline(area, &mut Buffer::empty(area));
        view.apply_scroll(TranscriptScroll::Home);
        view.render_inline(area, &mut Buffer::empty(area));
        let mut scroll = MouseScrollState::default();

        let _ = view.handle_mouse_event(
            mouse(
                MouseEventKind::Down(crossterm::event::MouseButton::Left),
                1,
                1,
            ),
            &mut scroll,
        );
        let _ = view.handle_mouse_event(
            mouse(
                MouseEventKind::Drag(crossterm::event::MouseButton::Left),
                1,
                6,
            ),
            &mut scroll,
        );
        let before_offset = view.scroll_offset();
        let before_head = view.selection.head;

        assert!(view.advance_drag_autoscroll(area));
        view.render_inline(area, &mut Buffer::empty(area));
        assert!(view.scroll_offset() > before_offset);
        assert!(view.selection.head.unwrap().line_index > before_head.unwrap().line_index);
    }

    #[test]
    fn mouse_up_stops_drag_autoscroll() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(20))]);
        let _ = render_test_view(&mut view, 10, 5);
        view.apply_scroll(TranscriptScroll::Home);
        let _ = render_test_view(&mut view, 10, 5);
        let mut scroll = MouseScrollState::default();

        let _ = view.handle_mouse_event(
            mouse(
                MouseEventKind::Down(crossterm::event::MouseButton::Left),
                1,
                1,
            ),
            &mut scroll,
        );
        let _ = view.handle_mouse_event(
            mouse(
                MouseEventKind::Drag(crossterm::event::MouseButton::Left),
                1,
                6,
            ),
            &mut scroll,
        );
        assert!(view.drag_autoscroll_active());

        let _ = view.handle_mouse_event(
            mouse(
                MouseEventKind::Up(crossterm::event::MouseButton::Left),
                1,
                6,
            ),
            &mut scroll,
        );

        assert!(!view.drag_autoscroll_active());
    }

    #[test]
    fn drag_horizontal_outside_clamps_column() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("alpha beta gamma"))]);
        let _ = render_test_view(&mut view, 10, 3);
        let mut scroll = MouseScrollState::default();

        let _ = view.handle_mouse_event(
            mouse(
                MouseEventKind::Down(crossterm::event::MouseButton::Left),
                1,
                2,
            ),
            &mut scroll,
        );
        assert_eq!(
            view.handle_mouse_event(
                mouse(
                    MouseEventKind::Drag(crossterm::event::MouseButton::Left),
                    99,
                    2
                ),
                &mut scroll,
            ),
            TranscriptMouseOutcome::SelectionChanged
        );
        assert_eq!(view.selection.head.map(|point| point.column), Some(9));
    }

    #[test]
    fn desired_height_saturates_at_u16_max_for_large_transcript() {
        let cells: Vec<Arc<dyn HistoryCell>> = (0..(usize::from(u16::MAX) + 10))
            .map(|_| Arc::new(TestCell("x")) as Arc<dyn HistoryCell>)
            .collect();
        let view = TranscriptView::new_transcript(cells);

        assert_eq!(view.desired_height(10), u16::MAX);
    }

    #[test]
    fn desired_height_returns_exact_sum_below_u16_max() {
        let view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(100))]);

        assert_eq!(view.desired_height(10), 100);
    }

    #[test]
    fn render_inline_bottom_aligns_in_nonzero_area_y() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("tail"))]);
        let area = Rect::new(0, 4, 8, 3);
        let mut buf = Buffer::empty(Rect::new(0, 0, 8, 8));

        view.render_inline(area, &mut buf);

        assert_eq!(
            area_lines(&buf, area),
            vec![
                "        ".to_string(),
                "        ".to_string(),
                "tail    ".to_string(),
            ]
        );
    }

    #[test]
    fn render_inline_scrolled_rows_remain_visible_in_nonzero_area_y() {
        let mut view =
            TranscriptView::new_transcript(vec![Arc::new(TestCell("one two three four"))]);
        let area = Rect::new(0, 4, 5, 2);
        let mut buf = Buffer::empty(Rect::new(0, 0, 5, 8));

        view.set_scroll_offset(1);
        view.render_inline(area, &mut buf);

        assert_eq!(
            area_lines(&buf, area),
            vec!["two  ".to_string(), "three".to_string()]
        );
    }

    fn ineffective_upward_scroll_keeps_follow_bottom(command: TranscriptScroll) {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("short"))]);
        let area = Rect::new(0, 0, 12, 4);
        let mut buf = Buffer::empty(area);

        view.render_inline(area, &mut buf);
        assert!(view.is_scrolled_to_bottom());

        view.apply_scroll(command);
        assert!(view.is_scrolled_to_bottom());

        for i in 0..8 {
            view.insert_cell(Arc::new(OwnedTestCell(format!("tail-{i}"))));
        }
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);

        assert!(view.is_scrolled_to_bottom());
        assert!(
            area_lines(&buf, area)
                .iter()
                .any(|line| line.contains("tail-7")),
            "expected newest tail to remain visible after ineffective upward scroll"
        );
    }

    #[test]
    fn page_up_on_short_transcript_keeps_follow_bottom() {
        ineffective_upward_scroll_keeps_follow_bottom(TranscriptScroll::PageUp);
    }

    #[test]
    fn home_on_short_transcript_keeps_follow_bottom() {
        ineffective_upward_scroll_keeps_follow_bottom(TranscriptScroll::Home);
    }

    #[test]
    fn mouse_wheel_up_on_short_transcript_keeps_follow_bottom() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("short"))]);
        let area = Rect::new(0, 0, 12, 4);
        let mut buf = Buffer::empty(area);

        view.render_inline(area, &mut buf);
        assert!(view.is_scrolled_to_bottom());

        let mut scroll = MouseScrollState::default();
        view.handle_mouse_event(mouse(MouseEventKind::ScrollUp, 1, 1), &mut scroll);
        assert!(view.is_scrolled_to_bottom());

        for i in 0..8 {
            view.insert_cell(Arc::new(OwnedTestCell(format!("tail-{i}"))));
        }
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);

        assert!(view.is_scrolled_to_bottom());
        assert!(
            area_lines(&buf, area)
                .iter()
                .any(|line| line.contains("tail-7")),
            "expected newest tail to remain visible after ineffective mouse wheel scroll"
        );
    }

    #[test]
    fn scroll_to_bottom_restores_follow_bottom_after_manual_scroll() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(20))]);
        let area = Rect::new(0, 0, 12, 4);
        let mut buf = Buffer::empty(area);

        view.render_inline(area, &mut buf);
        let at_tail = view.scroll_offset();
        view.apply_scroll(TranscriptScroll::PageUp);
        assert!(!view.is_scrolled_to_bottom());

        assert!(view.scroll_to_bottom());
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);

        assert!(view.is_scrolled_to_bottom());
        assert_eq!(view.scroll_offset(), at_tail);
    }

    #[test]
    fn scroll_to_bottom_keeps_follow_bottom_for_future_insert() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(FixedHeightCell(20))]);
        let area = Rect::new(0, 0, 12, 4);
        let mut buf = Buffer::empty(area);

        view.render_inline(area, &mut buf);
        view.apply_scroll(TranscriptScroll::PageUp);
        assert!(!view.is_scrolled_to_bottom());

        assert!(view.scroll_to_bottom());
        view.insert_cell(Arc::new(TestCell("newest")));
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);

        assert!(view.is_scrolled_to_bottom());
        assert!(
            area_lines(&buf, area)
                .iter()
                .any(|line| line.contains("newest")),
            "expected newest inserted cell to remain visible"
        );
    }

    #[test]
    fn scroll_to_latest_proposed_plan_returns_false_without_plan() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(TestCell("not a plan"))]);

        assert!(!view.scroll_to_latest_proposed_plan());
    }

    #[test]
    fn scroll_to_latest_proposed_plan_scrolls_to_completed_plan() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(FixedHeightCell(20)) as Arc<dyn HistoryCell>,
            Arc::new(new_proposed_plan("unique completed plan body".to_string())),
            Arc::new(FixedHeightCell(20)),
        ]);
        let area = Rect::new(0, 0, 40, 6);
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        view.scroll_to_bottom();

        assert!(view.scroll_to_latest_proposed_plan());

        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        let rendered = area_lines(&buf, area).join("\n");
        assert!(
            rendered.contains("unique completed plan body"),
            "expected completed plan body to be visible, got {rendered:?}"
        );
        assert!(!view.is_scrolled_to_bottom());
    }

    #[test]
    fn scroll_to_latest_proposed_plan_uses_latest_plan() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(new_proposed_plan("older plan body".to_string())) as Arc<dyn HistoryCell>,
            Arc::new(FixedHeightCell(20)),
            Arc::new(new_proposed_plan("newer plan body".to_string())),
            Arc::new(FixedHeightCell(20)),
        ]);
        let area = Rect::new(0, 0, 40, 6);
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        view.scroll_to_bottom();

        assert!(view.scroll_to_latest_proposed_plan());

        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        let rendered = area_lines(&buf, area).join("\n");
        assert!(
            rendered.contains("newer plan body"),
            "expected newest plan body to be visible, got {rendered:?}"
        );
        assert!(
            !rendered.contains("older plan body"),
            "expected older plan body to remain hidden, got {rendered:?}"
        );
    }

    #[test]
    fn scroll_to_latest_proposed_plan_starts_streamed_plan_group() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(FixedHeightCell(20)) as Arc<dyn HistoryCell>,
            Arc::new(new_proposed_plan_stream(
                vec![
                    vec!["• ".dim(), "Proposed Plan".bold()].into(),
                    Line::from("  first streamed chunk").style(proposed_plan_style()),
                ],
                false,
            )),
            Arc::new(new_proposed_plan_stream(
                vec![Line::from("  second streamed chunk").style(proposed_plan_style())],
                true,
            )),
            Arc::new(new_proposed_plan_stream(
                vec![Line::from("  third streamed chunk").style(proposed_plan_style())],
                true,
            )),
            Arc::new(FixedHeightCell(20)),
        ]);
        let area = Rect::new(0, 0, 40, 4);
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        view.scroll_to_bottom();

        assert!(view.scroll_to_latest_proposed_plan());

        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        let rendered = area_lines(&buf, area).join("\n");
        assert!(
            rendered.contains("first streamed chunk"),
            "expected first streamed plan chunk to be visible, got {rendered:?}"
        );
    }

    #[test]
    fn scroll_to_latest_proposed_plan_keeps_follow_bottom_when_plan_is_at_bottom() {
        let mut view = TranscriptView::new_transcript(vec![
            Arc::new(FixedHeightCell(20)) as Arc<dyn HistoryCell>,
            Arc::new(new_proposed_plan("bottom plan body".to_string())),
        ]);
        let area = Rect::new(0, 0, 40, 6);

        view.render_inline(area, &mut Buffer::empty(area));
        assert!(view.is_scrolled_to_bottom());

        assert!(view.scroll_to_latest_proposed_plan());
        assert!(view.is_scrolled_to_bottom());

        view.insert_cell(Arc::new(TestCell("newest tail")));
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);

        assert!(view.is_scrolled_to_bottom());
        assert!(
            area_lines(&buf, area)
                .iter()
                .any(|line| line.contains("newest tail")),
            "expected newest inserted cell to remain visible after /plan at bottom"
        );
    }

    #[test]
    fn scroll_to_latest_proposed_plan_keeps_follow_bottom_when_transcript_is_short() {
        let mut view = TranscriptView::new_transcript(vec![Arc::new(new_proposed_plan(
            "short plan body".to_string(),
        ))]);
        let area = Rect::new(0, 0, 40, 12);

        view.render_inline(area, &mut Buffer::empty(area));
        assert!(view.is_scrolled_to_bottom());

        assert!(view.scroll_to_latest_proposed_plan());
        assert!(view.is_scrolled_to_bottom());

        for i in 0..8 {
            view.insert_cell(Arc::new(OwnedTestCell(format!("tail-{i}"))));
        }
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);

        assert!(view.is_scrolled_to_bottom());
        assert!(
            area_lines(&buf, area)
                .iter()
                .any(|line| line.contains("tail-7")),
            "expected newest tail to remain visible after /plan on short transcript"
        );
    }

    #[test]
    fn highlight_older_chunk_clears_bottom_stickiness() {
        let cells = (0..12)
            .map(|i| Arc::new(OwnedTestCell(format!("line-{i:02}"))) as Arc<dyn HistoryCell>)
            .collect();
        let mut view = TranscriptView::new_transcript(cells);
        let area = Rect::new(0, 0, 12, 4);
        let mut buf = Buffer::empty(area);

        view.render_inline(area, &mut buf);
        assert!(view.is_scrolled_to_bottom());

        view.set_highlight_cell(Some(0));
        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        assert!(!view.is_scrolled_to_bottom());
        assert!(
            area_lines(&buf, area)
                .iter()
                .any(|line| line.contains("line-00")),
            "expected highlighted older chunk to be visible"
        );

        let mut buf = Buffer::empty(area);
        view.render_inline(area, &mut buf);
        assert!(!view.is_scrolled_to_bottom());
        assert!(
            area_lines(&buf, area)
                .iter()
                .any(|line| line.contains("line-00")),
            "expected highlighted older chunk to remain visible after another frame"
        );
    }
}
