//! ScrollbackPane widget - the main conversation display. use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::Style; use ratatui::widgets::StatefulWidget; use std::ops::Range; use crate::render::SafeBuf; use crate::render::color::{blend_color, fade_region}; use crate::scrollback::block::{BlockContent, RenderBlock}; use crate::scrollback::entry::ScrollbackEntry; use crate::scrollback::layout::HorizontalLayout; use crate::scrollback::render::{ScratchBuffer, render_scrolled_entries_with_selection_boundaries}; use crate::scrollback::selection::{RenderOutput, ScrollInfo, SelectionBox}; use crate::scrollback::state::{ScrollbackState, ViewMode}; use crate::scrollback::sticky::{PromptDescriptor, StickyHeaderLayout, compute_sticky_layout}; use crate::scrollback::text_selection::ResolvedSelectionBoundaries; use crate::scrollback::types::{BlockContext, DisplayMode}; use crate::theme::Theme; /// Scrollback pane widget. /// /// Displays conversation entries with optional pinned header for the current turn's prompt. /// /// # Scratch Buffers /// For efficiency, scratch buffers should be owned by the caller and reused across frames. /// Use `render_with_scratch()` for optimal performance. The `StatefulWidget::render()` impl /// creates a temporary scratch buffer for API compatibility but is less efficient. #[derive(Debug, Clone, Default)] pub struct ScrollbackPane { pub is_active: bool, pub mouse_pos: Option<(u16, u16)>, pub dim_from_entry: Option, /// Index of the entry currently under the mouse cursor (post-`hit_test`). /// Used to paint a hover bg + swap the indicator for tool-call entries. /// Hover painting still fires when `is_active` is false — hover is a /// mouse affordance, not a focus indicator. pub hovered_entry: Option, /// When set, every visible content row is post-painted to invert cells /// matching this regex (scrollback search). `None` disables highlighting. pub search_highlight: Option, /// Absolute paths of media generated in this transcript, used to resolve the /// short relative paths the model prints (`images/1.jpg`) into clickable /// `file://` links. Empty disables relative-path resolution. pub media_paths: Vec, } #[derive(Debug, Clone, Default)] pub(crate) struct RenderOutputWithSelectionBoundaries { pub(crate) output: RenderOutput, pub(crate) selection_boundaries: ResolvedSelectionBoundaries, } impl ScrollbackPane { pub fn new() -> Self { Self { is_active: false, mouse_pos: None, dim_from_entry: None, hovered_entry: None, search_highlight: None, media_paths: Vec::new(), } } pub fn active(mut self, active: bool) -> Self { self.is_active = active; self } /// Set mouse position for timestamp hover detection. pub fn with_mouse_pos(mut self, pos: (u16, u16)) -> Self { self.mouse_pos = Some(pos); self } pub fn with_dim_from(mut self, dim_from: Option) -> Self { self.dim_from_entry = dim_from; self } pub fn with_hovered_entry(mut self, hovered: Option) -> Self { self.hovered_entry = hovered; self } /// Set the regex whose matches are inverted on every visible content row. pub fn with_search_highlight(mut self, re: Option) -> Self { self.search_highlight = re; self } /// Set the transcript's generated-media paths used to resolve relative /// file-path link targets (`images/1.jpg`) into clickable `file://` links. pub fn with_media_paths(mut self, media_paths: Vec) -> Self { self.media_paths = media_paths; self } } impl StatefulWidget for ScrollbackPane { type State = ScrollbackState; fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) { // Create a temporary scratch buffer for API compatibility. // For better performance, use render_with_scratch() with a reusable scratch buffer. let mut scratch = ScratchBuffer::new(); self.render_with_scratch(area, buf, state, &mut scratch); } } impl ScrollbackPane { /// Render the scrollback pane with an externally-owned scratch buffer. /// /// This is the preferred method for rendering - the scratch buffer should be /// created once and reused across frames to avoid allocations. /// /// # TODO: Make render pure (Phase 2-5) /// /// After implementing `prepare_layout()` in ScrollbackState, this method should /// take `state: &ScrollbackState` (immutable) instead of `&mut ScrollbackState`. /// All state mutations will move to `prepare_layout()`, making render truly pure. /// /// # TODO: Consider StatefulWidget-like pattern /// /// Once state is immutable, we could have a pattern like: /// ```ignore /// trait PureWidget { /// type ReadState; /// type ScratchState; /// fn render(&self, area: Rect, buf: &mut Buffer, /// state: &Self::ReadState, scratch: &mut Self::ScratchState); /// } /// ``` /// This separates read-only state from mutable scratch/working memory. /// /// Returns RenderOutput containing elements to render in a post-pass (e.g., selection box, scroll info). pub fn render_with_scratch( self, area: Rect, buf: &mut Buffer, state: &ScrollbackState, scratch: &mut ScratchBuffer, ) -> RenderOutput { self.render_with_scratch_and_selection_boundaries(area, buf, state, scratch) .output } pub(crate) fn render_with_scratch_and_selection_boundaries( self, area: Rect, buf: &mut Buffer, state: &ScrollbackState, scratch: &mut ScratchBuffer, ) -> RenderOutputWithSelectionBoundaries { if area.width == 0 || area.height == 0 { return RenderOutputWithSelectionBoundaries::default(); } let (scroll_offset, viewport_height, total_height) = state.scroll_info(); let scroll_info = ScrollInfo { scroll_offset, viewport_height, total_height, }; let theme = Theme::current(); // All layout preparation (viewport, heights, follow mode, cache // invalidation) happens in state.prepare_layout(), called before // render, keeping render as close to pure as possible. // Scrollbar is rendered by Viewport, not here. let mut output = match state.view_mode() { ViewMode::SingleTurn => self.render_single_turn(area, buf, state, &theme, scratch), ViewMode::AllTurns => { let full_range = 0..state.len(); self.render_with_sticky_headers(area, buf, state, &theme, full_range, scratch) } }; // Hover affordance for tool-call entries: paint a hover bg + swap // the bullet to a chevron when collapsed/foldable. Runs whether or // not the scrollback owns focus — hover is a mouse signal. self.render_tool_call_hover(buf, area, state, &theme); output.output.scroll_info = Some(scroll_info); output } /// Paint hover affordance for foldable header-style blocks /// (`ToolCall`, `Thinking`, `BgTask`, `Subagent`). /// /// - Skips when nothing is hovered, or when the hovered entry is the /// currently selected entry (selection wins; avoids double-paint). /// - Skips entries that aren't header-style — markdown / agent message /// blocks already get the hover *border* via /// `agent::render_entry_hover` and don't need a full bg patch. /// - Hover bg is `blend(bg_base, bg_dark, 0.5)` so it's strictly /// dimmer than the selection bg (`bg_dark`) across all themes. /// - Inset matches the group-selection bg rule (skip 1 col on each /// side unless `display.highlight_overlays_border` is set). /// - Chevron paint is shared with the selected-entry path via /// [`paint_expandable_indicator`]. `BgTask` / `Subagent` aren't /// foldable so the chevron is a no-op there, but the hover bg /// still paints to match the other collapsed tool-call rows. fn render_tool_call_hover( &self, buf: &mut Buffer, area: Rect, state: &ScrollbackState, theme: &Theme, ) { let Some(hover_idx) = self.hovered_entry else { return; }; if state.selected() == Some(hover_idx) { return; } let Some(entry) = state.entry(hover_idx) else { return; }; if !matches!( entry.block, crate::scrollback::block::RenderBlock::ToolCall(_) | crate::scrollback::block::RenderBlock::Thinking(_) | crate::scrollback::block::RenderBlock::BgTask(_) | crate::scrollback::block::RenderBlock::Subagent(_) ) { return; } // Skip hover bg whenever the entry isn't collapsed. Once the user // has folded the entry open (Truncated for `Execute`/`Other` while // streaming, `Expanded` for Edit/markdown), the row already has // line-level styling — diff green/red, stdout `bg_dark`, etc. — // and a full bg patch either clobbers it or is redundant. // The hover border (`render_entry_hover`) still fires to indicate // hover; the chevron paint below is a no-op for non-collapsed // entries anyway. if entry.display_mode != DisplayMode::Collapsed { return; } let Some((entry_area, top_clipped, _bottom_clipped)) = state.entry_screen_area(hover_idx, area) else { return; }; let display_cfg = &state.appearance().scrollback.display; let hover_bg = blend_color(theme.bg_base, theme.bg_dark, 0.5).unwrap_or(theme.bg_dark); let bg_style = Style::default().bg(hover_bg); // Inset the hover bg by 1 column on each side unless the appearance // config opts into overlaying the border, mirroring the group // selection bg behaviour. let (hl_x, hl_width) = if display_cfg.highlight_overlays_border { (entry_area.x, entry_area.width) } else { (entry_area.x + 1, entry_area.width.saturating_sub(2)) }; if hl_width > 0 { for y in entry_area.y..entry_area.y + entry_area.height { if y >= area.y && y < area.y + area.height { for x in hl_x..hl_x + hl_width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(bg_style); } } } } } // Swap ◆ → › on the bullet row when the entry is foldable + // collapsed (or running and at min fold mode). Same predicate as // the selected-entry chevron paint. An expanded verb slot hovers ⌄ // on its header row; when the header is top-clipped off-screen the // first visible row is member 0's, which takes the normal ›. let verb_expanded = state .get_cached_entry_layouts() .and_then(|l| l.get(hover_idx)) .is_some_and(|i| i.verb_group_header && i.group_collapse_header); paint_expandable_indicator( buf, area, entry_area.y, state.appearance(), entry, verb_expanded && !top_clipped, ); } // Unified sticky header rendering for both SingleTurn and AllTurns modes. // // The key insight: SingleTurn is just AllTurns with a filtered range. // Both modes use the same gradual header collapse logic via compute_sticky_layout(). // // For SingleTurn: // - visible_range = state.visible_entry_range() (entries 0..N of the current turn) // - Prompt descriptors have y_virtual relative to range start // - The first entry (prompt) becomes a sticky header when scrolled past // // For AllTurns: // - visible_range = 0..state.len() (all entries) // - Prompt descriptors have y_virtual as cumulative from entry 0 // - Multiple prompts can become sticky headers as user scrolls /// Render in SingleTurn mode using unified sticky header logic. fn render_single_turn( &self, area: Rect, buf: &mut Buffer, state: &ScrollbackState, theme: &Theme, scratch: &mut ScratchBuffer, ) -> RenderOutputWithSelectionBoundaries { let visible_range = state.visible_entry_range(); self.render_with_sticky_headers(area, buf, state, theme, visible_range, scratch) } /// Render entries with sticky section headers. /// /// This is the unified rendering path for both SingleTurn and AllTurns modes. /// Prompts within the entry_range act as section headers that stick to the top /// when scrolled past, and get pushed off by the next approaching prompt. /// /// - For SingleTurn: entry_range = visible_entry_range() (one turn's entries) /// - For AllTurns: entry_range = 0..len() (all entries) /// /// Returns RenderOutput containing selection box (if any) to be rendered after. fn render_with_sticky_headers( &self, area: Rect, buf: &mut Buffer, state: &ScrollbackState, theme: &Theme, entry_range: Range, scratch: &mut ScratchBuffer, ) -> RenderOutputWithSelectionBoundaries { let layout_cfg = &state.appearance().scrollback.layout; let layout = HorizontalLayout::new(area, layout_cfg); let entry_content_width = layout.entry_content_area().width; // For SingleTurn, this gives us relative y_virtual coordinates. // For AllTurns, this is equivalent to the full range. let prompts = self.build_prompt_descriptors_for_range( state, entry_content_width, theme, entry_range.clone(), ); // Compute sticky header layout (disabled in compact mode). let use_sticky = state.appearance().scrollback.display.sticky_headers && !state.appearance().prompt.compact; let sticky = if use_sticky { compute_sticky_layout(state.scroll_offset(), area.height, &prompts) } else { StickyHeaderLayout::default() }; // The sticky layout encapsulates all 1D coordinate math. // We just ask it for screen positions and scroll offsets. let header_height = sticky.header_screen_rows(); let content_area = if header_height > 0 && header_height < area.height { Rect { x: area.x, y: area.y + header_height, width: area.width, height: area.height.saturating_sub(header_height), } } else if header_height >= area.height { // Header takes entire viewport (edge case). // Clamp y to the last valid row of area so it stays within the // buffer, even though height=0 means nothing will render here. Rect { x: area.x, y: area.y + area.height.saturating_sub(1), width: area.width, height: 0, } } else { area }; let mut pushed_header_selection_box: Option = None; if let Some(ref pushed) = sticky.pushed { let visible_height = pushed.visible_height(); if visible_height > 0 { let screen_row = sticky.pushed_screen_row().unwrap_or(0); let header_area = Rect { x: area.x, y: area.y + screen_row, width: area.width, height: visible_height, }; self.render_sticky_header( buf, header_area, state, pushed.entry_idx, theme, pushed.render_height, pushed.clip_top, scratch, self.is_active && state.selected() == Some(pushed.entry_idx), self.mouse_pos, ); // Fade out the pushed header as it's being pushed off. // The fade makes the transition smoother visually. // opacity = visible_rows / (full_height + 1) // So even a fully visible pushed header (clip_top=0) starts at 80% for 4-row header. let opacity = visible_height as f32 / (pushed.render_height.saturating_add(1)) as f32; fade_region(buf, header_area, theme.bg_base, opacity); if self.is_active && state.selected() == Some(pushed.entry_idx) { let layout = HorizontalLayout::new(header_area, layout_cfg); let selection_area = layout.selection_area(); // Selection border fades with content. // If needed, use opacity.max(0.5) to enforce a minimum visibility floor. let border_color = blend_color(theme.bg_base, theme.selection_border, opacity) .unwrap_or(theme.selection_border); // Top is clipped only when content is actually being clipped off (clip_top > 0) // When clip_top == 0, the full header is visible (just fading), so show full border let top_clipped = pushed.clip_top > 0; let sel_box = SelectionBox::new(selection_area, Style::default().fg(border_color)) .with_top_clipped(top_clipped) .with_bottom_clipped(false); pushed_header_selection_box = Some(sel_box); } } } // Render pinned header (if any) - the main sticky header let mut pinned_header_selection: Option<(Rect, usize)> = None; if let Some(ref pinned) = sticky.pinned { let visible_height = pinned.visible_height(); if visible_height > 0 { let screen_row = sticky.pinned_screen_row().unwrap_or(0); let header_area = Rect { x: area.x, y: area.y + screen_row, width: area.width, height: visible_height, }; self.render_sticky_header( buf, header_area, state, pinned.entry_idx, theme, pinned.render_height, pinned.clip_top, scratch, self.is_active && state.selected() == Some(pinned.entry_idx), self.mouse_pos, ); // For selection, use HorizontalLayout to match content entries' selection width let layout = HorizontalLayout::new(header_area, layout_cfg); let selection_area = layout.selection_area(); pinned_header_selection = Some((selection_area, pinned.entry_idx)); } } // NOTE: The gap row is part of the header area height. // It should naturally be empty since we don't render anything there. let mut pinned_header_selection_box: Option = None; if self.is_active && let Some((selection_area, entry_idx)) = pinned_header_selection && state.selected() == Some(entry_idx) { // Selection box for sticky header: // - y_top = selection_area.y (first row of selection content) // - y_bottom = selection_area.y + selection_area.height - 1 (last row) // - top_clipped = false if there's a gap row above for corners // - bottom_clipped = false since there's always a gap row between header and content let screen_row = sticky.pinned_screen_row().unwrap_or(0); // Check if there's room for top corners let top_clipped = screen_row == 0 && area.y == 0; let sel_box = SelectionBox::new(selection_area, Style::default().fg(theme.selection_border)) .with_top_clipped(top_clipped) .with_bottom_clipped(false); pinned_header_selection_box = Some(sel_box); } if content_area.height > 0 { let visible_range = entry_range.clone(); // Use the sticky layout's scroll_for_content() which maintains bottom line continuity. let scroll_for_content = sticky.scroll_for_content(state.scroll_offset()); // Get the entry index shown in the pinned header (to avoid duplicate selection) let pinned_entry_idx = sticky.pinned_entry_idx(); let mut content_output = self.render_content( area, content_area, buf, state, theme, visible_range, scroll_for_content, 0, // In AllTurns mode, we may have already computed selection for pinned header. // Pass None to avoid duplicate selection box computation in render_content. None, pinned_entry_idx, pinned_header_selection_box.is_some(), scratch, ); // Return the selection box - prioritize pushed > pinned > content let content_selection_box = content_output.output.selection_box.take(); content_output.output.selection_box = pushed_header_selection_box .or(pinned_header_selection_box) .or(content_selection_box); content_output } else { // Return header selection if we have one (pushed > pinned) RenderOutputWithSelectionBoundaries { output: RenderOutput { selection_box: pushed_header_selection_box.or(pinned_header_selection_box), ..Default::default() }, ..Default::default() } } } /// Build prompt descriptors for a range of entries. /// /// The y_virtual coordinates are relative to the range start, not absolute. /// This allows the same sticky layout logic to work for both: /// - SingleTurn: range = visible_entry_range() (one turn's entries) /// - AllTurns: range = 0..len() (all entries) /// /// NOTE: Uses cached data from prepare_layout(); heights are not /// recomputed here, they come from the LayoutCache. fn build_prompt_descriptors_for_range( &self, state: &ScrollbackState, // Kept for API compat; unused since heights come from cache. _entry_content_width: u16, _theme: &Theme, entry_range: Range, ) -> Vec { let cached_descriptors = state .get_cached_prompt_descriptors() .expect("layout cache must be valid - was prepare_layout() called?"); let cached_virtual_y = state .get_cached_virtual_y() .expect("layout cache must be valid - was prepare_layout() called?"); // Get the y_offset of the first entry in the range // All y_virtual values will be adjusted relative to this let y_offset = cached_virtual_y .get(entry_range.start) .copied() .unwrap_or(0); cached_descriptors .iter() .filter(|p| entry_range.contains(&p.entry_idx)) .map(|p| PromptDescriptor { entry_idx: p.entry_idx, y_virtual: p.y_virtual.saturating_sub(y_offset), full_height: p.full_height, min_height: p.min_height, sticky: p.sticky, }) .collect() } /// Render a sticky header (pushed or pinned). /// /// - `render_height`: Total height budget for the block (including vpads) /// - `clip_top`: If > 0, clips rendered output from top (for push effect) #[allow(clippy::too_many_arguments)] fn render_sticky_header( &self, buf: &mut Buffer, area: Rect, state: &ScrollbackState, entry_idx: usize, theme: &Theme, render_height: u16, clip_top: u16, scratch: &mut ScratchBuffer, is_selected: bool, mouse_pos: Option<(u16, u16)>, ) { let appearance = state.appearance(); let Some(entry) = state.entry(entry_idx) else { return; }; let layout = HorizontalLayout::new(area, &appearance.scrollback.layout); // The block adds vpad (2 rows) if has_vpad is true. let cwd = state.cwd(); let has_vpad = entry .block .has_vpad(&entry.context(area.width, appearance, cwd)); let vpad_rows = if has_vpad { 2 } else { 0 }; let content_lines = render_height.saturating_sub(vpad_rows); // User prompts use their actual display mode so collapsed prompts // stay truncated (3 lines + ellipsis) in sticky headers. // Other blocks use Expanded + max_lines budget as before. let mode = if entry.block.is_user_prompt() { entry.display_mode() } else { DisplayMode::Expanded }; // When timestamps are shown on message blocks, reserve right margin in the // block's content width so wrapped text doesn't collide with the overlaid // timestamp (matches behavior in EntryRenderer for normal content). let ts_reserved = if appearance.show_timestamps && matches!( &entry.block, RenderBlock::UserPrompt(_) | RenderBlock::AgentMessage(_) | RenderBlock::Btw(_) ) { 10 } else { 0 }; let content_width_for_block = layout.content_width().saturating_sub(ts_reserved); let ctx = entry.context_with_mode_and_budget( content_width_for_block, mode, content_lines, appearance, is_selected, cwd, ); if clip_top > 0 { // For pushed headers being pushed OFF screen: // - The TOP rows disappear first (pushed up, out of view) // - The BOTTOM rows stay visible longest // // We render the full header to a scratch buffer, then copy only // the bottom (visible) rows to the actual buffer. let visible_height = render_height.saturating_sub(clip_top); if visible_height == 0 { return; } // Use reusable scratch buffer (avoids allocation per frame) let scratch_buf = scratch.prepared(area.width, render_height); let scratch_area = Rect::new(0, 0, area.width, render_height); Self::render_entry_with_ctx_static( entry, &ctx, theme, scratch_area, scratch_buf, mouse_pos, ); for dy in 0..visible_height { let src_y = clip_top + dy; let dst_y = area.y + dy; for dx in 0..area.width { if let Some(src_cell) = scratch_buf.cell((dx, src_y)) && let Some(dst_cell) = buf.cell_mut((area.x + dx, dst_y)) { *dst_cell = src_cell.clone(); } } } } else { // No clipping needed - render directly with max_lines Self::render_entry_with_ctx_static(entry, &ctx, theme, area, buf, mouse_pos); } } /// Render an entry with a specific BlockContext (for max_lines support). /// Static method to avoid borrow issues. /// /// `mouse_pos` is forwarded for timestamp hover expansion in sticky headers. fn render_entry_with_ctx_static( entry: &ScrollbackEntry, ctx: &BlockContext, theme: &Theme, area: Rect, buf: &mut Buffer, mouse_pos: Option<(u16, u16)>, ) { use crate::scrollback::types::BlockBackground; let layout = HorizontalLayout::new(area, &ctx.appearance.scrollback.layout); // Use the actual content area (not entry_content_area which includes accent) let content_area = layout.content; let left_pad = layout.left_padding; let right_pad = layout.right_padding; let output = entry.block.output(ctx); let block_has_vpad = entry.block.has_vpad(ctx); let accent_has_bg = entry.block.accent_background(ctx); let block_bg = entry.block.background(ctx); let bg_color = match block_bg { BlockBackground::None => None, BlockBackground::Light => Some(theme.bg_light), BlockBackground::Dark => Some(theme.bg_dark), }; // Only use vpad if there's enough room for vpad + at least 1 content line. // Need at least 3 rows: vpad_top (1) + content (1) + vpad_bottom (1) // If less space, skip vpad to prioritize content. let use_vpad = block_has_vpad && content_area.height >= 3; let content_height = output.len() as u16; let total_height = content_height + if use_vpad { 2 } else { 0 }; // Fill the entire entry area with block background (if any) // This includes vpad rows, content rows, and padding columns if let Some(bg) = bg_color { let bg_style = ratatui::style::Style::default().bg(bg); let fill_height = total_height.min(area.height); for y in content_area.y..content_area.y + fill_height { for x in left_pad.x..left_pad.x + left_pad.width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(bg_style); } } for x in content_area.x..content_area.x + content_area.width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(bg_style); } } for x in right_pad.x..right_pad.x + right_pad.width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(bg_style); } } } } // Render vpad top if needed (skip 1 row) let mut y = content_area.y; if use_vpad { y += 1; } // Render content lines in the actual content area (after left padding) for line in &output.lines { if y >= content_area.y + content_area.height { break; } buf.set_line_safe(content_area.x, y, &line.content, content_area.width); y += 1; } // Overlay timestamp on the first content line for message blocks in sticky // headers (pinned/pushed user prompts, agent messages, etc.). Mirrors the // overlay in EntryRenderer but adapted for the header render path + clip // (scratch buffer) case. Hover expansion works for pinned (absolute coords); // pushed headers always get the short format. if ctx.appearance.show_timestamps && !output.lines.is_empty() && matches!( &entry.block, RenderBlock::UserPrompt(_) | RenderBlock::AgentMessage(_) | RenderBlock::Btw(_) ) && let Some(ts) = entry.created_at { let first_content_y = content_area.y + if use_vpad { 1 } else { 0 }; let ts_hovered = mouse_pos.is_some_and(|(mx, my)| { my == first_content_y && mx >= content_area.x + content_area.width.saturating_sub(10) && mx < content_area.x + content_area.width }); let ts_str = if ts_hovered { ts.format(" %H:%M:%S | %b %d").to_string() } else { ts.format(" %-I:%M %p").to_string() }; let ts_width = ts_str.len() as u16; if content_area.width > ts_width + 1 && first_content_y < content_area.y + content_area.height { let ts_x = content_area.x + content_area.width - ts_width; let ts_style = Style::default().fg(theme.gray); buf.set_string_safe(ts_x, first_content_y, &ts_str, ts_style); } } // vpad bottom is just empty space - no need to track y further // Draw accent line if entry has one, otherwise clear the accent column // so stale content from previous frames doesn't bleed through. if let Some(accent) = entry.block.accent(ctx) { let color = accent.color; let accent_area = layout.accent; let accent_bg = if accent_has_bg { match block_bg { BlockBackground::None => theme.bg_base, BlockBackground::Light => theme.bg_light, BlockBackground::Dark => theme.bg_dark, } } else { theme.bg_base }; for y in accent_area.y..accent_area.y + total_height.min(area.height) { if let Some(cell) = buf.cell_mut((accent_area.x, y)) { cell.set_char('┃'); cell.set_style(ratatui::style::Style::default().fg(color).bg(accent_bg)); } } } else { // No accent: clear the column with the block's bg so it matches. let accent_area = layout.accent; let clear_bg = bg_color.unwrap_or(theme.bg_base); let clear_style = ratatui::style::Style::default().bg(clear_bg); for y in accent_area.y..accent_area.y + total_height.min(area.height) { if let Some(cell) = buf.cell_mut((accent_area.x, y)) { cell.set_char(' '); cell.set_style(clear_style); } } } } /// Render the main content area (shared between SingleTurn and AllTurns). /// /// Returns the selection box for content entries (if any), to be rendered by the frame. #[allow(clippy::too_many_arguments)] fn render_content( &self, area: Rect, content_area: Rect, buf: &mut Buffer, state: &ScrollbackState, theme: &Theme, visible_range: Range, scroll_for_content: usize, // Unused; total_height now comes from the cache instead. _prompt_content_height: u16, pinned_header_selection_area: Option, // Entry idx shown in header (to skip in content selection). pinned_entry_idx: Option, // Whether selection was already computed for the header. header_has_selection: bool, _scratch: &mut ScratchBuffer, ) -> RenderOutputWithSelectionBoundaries { let all_layouts = state .get_cached_entry_layouts() .expect("layout cache must be valid - was prepare_layout() called?"); // O(log n) paint window: only entries that can intersect the content // viewport. Avoids collecting/walking the full AllTurns history each frame. // Group headers (verb or truncation) that land in the window extend the // end of the slice through the rest of their run so the aggregated header // labels still see off-screen members (counts/tense/failures) without // re-collecting all history. let (paint_range, content_y0) = state.paint_window( visible_range.clone(), scroll_for_content, content_area.height as usize, ); let entries: Vec<&ScrollbackEntry> = state.entries_in_range(paint_range.clone()); let entry_layouts_cache = all_layouts .get(paint_range.clone()) .expect("paint_range out of bounds for cached layouts"); // paint_window keeps the window inside the visible range. let entry_index_base = paint_range.start - visible_range.start; // Selection / dim indices are relative to the full visible range (not the // paint window); entry_index_base remaps slice indices inside the renderer. let visible_start = visible_range.start; let relative_selected = if self.is_active { state .selected() .filter(|&s| visible_range.contains(&s)) .map(|s| s - visible_start) } else { None }; let relative_dim_from = self .dim_from_entry .filter(|&d| d >= visible_range.start && d < visible_range.end) .map(|d| d - visible_start); let rendered = render_scrolled_entries_with_selection_boundaries( buf, content_area, &entries, scroll_for_content, relative_selected, theme, state.appearance(), entry_layouts_cache, state.current_tick(), self.mouse_pos, relative_dim_from, self.search_highlight.as_ref(), content_y0, entry_index_base, &self.media_paths, Some((state.group_spans(), paint_range.start)), state.cwd(), ); let result = rendered.result; let selection_boundaries = rendered.selection_boundaries; // NOTE: total_height comes from prepare_layout() (called before render) // and isn't updated here; result.total_height is only used locally, // if needed for debugging. // Capture selected entry's screen area for inline button positioning. let selected_entry_rect = result.selected_area.as_ref().map(|s| s.area); let selected_area = result.selected_area; let mut content_output = RenderOutputWithSelectionBoundaries { output: RenderOutput { selection_model: result.selection_model, link_overlay: result.link_overlay, inline_media: result.inline_media, diagram_affordances: result.diagram_affordances, ..Default::default() }, selection_boundaries, }; // Post-render: highlight the selected entry's rows with `bg_dark`. // // Fires when: // 1. The selected entry is part of a multi-entry group (so the // individual selected row stands out within the group), OR // 2. The selected entry is a header-style foldable block // (`ToolCall`, `Thinking`) — singletons get the same bg so // selection looks consistent across grouped vs lone entries. // // Skipped when the selected entry isn't collapsed — once the row // is folded open (Truncated for `Execute`/`Other` while streaming, // `Expanded` for Edit/markdown), a full bg fill is too heavy and // would clobber line-level styling (diff green/red, stdout // `bg_dark`, etc.). The SelectionBox border alone is enough. // // Other singleton blocks (markdown messages, user prompts, etc.) // intentionally don't get the bg patch — for big markdown blocks // a full bg fill would be too heavy and the SelectionBox border // alone is enough. // // The highlight is inset by 1 column on each side unless // `display.highlight_overlays_border` is set, to avoid clobbering // the SelectionBox border characters (│). if self.is_active && let Some(ref selected) = selected_area && let Some(selected_abs) = state.selected() { let display_cfg = &state.appearance().scrollback.display; let split_mode = display_cfg.group_selection_split; let sel_range = state.group_range_of(selected_abs, split_mode); let is_header_style_block = state.entry(selected_abs).is_some_and(|e| { matches!( e.block, crate::scrollback::block::RenderBlock::ToolCall(_) | crate::scrollback::block::RenderBlock::Thinking(_) ) }); let is_collapsed = state .entry(selected_abs) .is_some_and(|e| e.display_mode == DisplayMode::Collapsed); if (sel_range.len() > 1 || is_header_style_block) && is_collapsed { let highlight_area = selected.area; let bg_style = Style::default().bg(theme.bg_dark); let (hl_x, hl_width) = if display_cfg.highlight_overlays_border { (highlight_area.x, highlight_area.width) } else { (highlight_area.x + 1, highlight_area.width.saturating_sub(2)) }; if hl_width > 0 { for y in highlight_area.y..highlight_area.y + highlight_area.height { if y >= content_area.y && y < content_area.y + content_area.height { for x in hl_x..hl_x + hl_width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(bg_style); } } } } } } // Expandable indicator: replace the bullet character with "›" (or configured char) // when the selected entry is foldable and at its minimum fold mode. // Works for both grouped and singleton entries. In an expanded // verb-group slot the selection acts as MEMBER 0, so the caret // sits on the member row (below the header line) pointing right; // the ⌄ group affordance lives on the hover pass instead. if let Some(entry) = state.entry(selected_abs) { let verb_expanded = state .get_cached_entry_layouts() .and_then(|l| l.get(selected_abs)) .is_some_and(|i| i.verb_group_header && i.group_collapse_header); // Bottom-clip that cuts the member row off is handled by the // paint fn's bounds guard (the offset row is simply skipped). paint_expandable_indicator( buf, content_area, verb_member_indicator_row(selected.area.y, verb_expanded, selected.top_clipped), state.appearance(), entry, false, ); } } // Compute selection box for content entries // Skip if: // 1. Selection was already computed for header (header_has_selection is true), OR // 2. The selected entry is the pinned header entry and pinned_header_selection_area is set // (for SingleTurn mode where selection is computed later) let skip_content_selection = header_has_selection || (pinned_entry_idx.is_some() && state.selected() == pinned_entry_idx && pinned_header_selection_area.is_some()); if self.is_active && !skip_content_selection && let Some(ref selected) = selected_area && let Some(selected_abs) = state.selected() { let split_mode = state.appearance().scrollback.display.group_selection_split; let sel_range = state.group_range_of(selected_abs, split_mode); if sel_range.len() <= 1 { // Singleton (non-groupable, or expanded in Mode B, or lone groupable): // use the individual entry's area directly. let top_clipped = selected.top_clipped || (selected.area.y == content_area.y && content_area.y == 0); let bottom = selected.area.y + selected.area.height; let bottom_clipped = selected.bottom_clipped || bottom > content_area.y + content_area.height; let sel_box = SelectionBox::new(selected.area, Style::default().fg(theme.selection_border)) .with_top_clipped(top_clipped) .with_bottom_clipped(bottom_clipped); content_output.output.selection_box = Some(sel_box); content_output.output.selected_entry_area = selected_entry_rect; return content_output; } // Multi-entry group: compute a Rect spanning from first to last entry in the range. // Use virtual_y from cache to find screen positions. if let Some(all_virtual_y) = state.get_cached_virtual_y() && let Some(all_layouts) = state.get_cached_entry_layouts() { // Virtual y positions (relative to visible range start) let base_y = all_virtual_y[visible_range.start]; let group_start_vy = all_virtual_y[sel_range.start] - base_y; let last_idx = sel_range.end - 1; let group_end_vy = all_virtual_y[last_idx] - base_y + all_layouts[last_idx].height as usize; // Convert virtual y to screen y. Cumulative positions stay usize // (tall sessions exceed u16::MAX); the screen y/height // below are viewport-relative and provably fit in u16. let viewport_start = scroll_for_content; let viewport_end = scroll_for_content + content_area.height as usize; let visible_start_vy = group_start_vy.max(viewport_start); let visible_end_vy = group_end_vy.min(viewport_end); if visible_start_vy < visible_end_vy { let screen_y = content_area.y + (visible_start_vy - viewport_start) as u16; let visible_height = (visible_end_vy - visible_start_vy) as u16; // Use HorizontalLayout for consistent selection width let layout_cfg = &state.appearance().scrollback.layout; let row_layout = HorizontalLayout::new( Rect::new(content_area.x, screen_y, content_area.width, visible_height), layout_cfg, ); let sel_area = row_layout.selection_area(); let top_clipped = group_start_vy < viewport_start; let bottom_clipped = group_end_vy > viewport_end; let sel_box = SelectionBox::new(sel_area, Style::default().fg(theme.selection_border)) .with_top_clipped(top_clipped) .with_bottom_clipped(bottom_clipped); content_output.output.selection_box = Some(sel_box); content_output.output.selected_entry_area = selected_entry_rect; return content_output; } } } // Compute selection box for pinned header (SingleTurn mode) if self.is_active && let Some(sel_area) = pinned_header_selection_area { let top_clipped = area.y == 0; // Bottom is not clipped since there's a gap row (pinned_height includes gap) let bottom_clipped = false; let sel_box = SelectionBox::new(sel_area, Style::default().fg(theme.selection_border)) .with_top_clipped(top_clipped) .with_bottom_clipped(bottom_clipped); content_output.output.selection_box = Some(sel_box); return content_output; } content_output } } /// Screen row the selection caret belongs on within an entry's slot. An /// expanded verb-group slot stacks the header line above member 0's own row /// and the caret is the MEMBER's affordance, so it sits one row below the /// slot top — unless the header is top-clipped off-screen, in which case the /// slot's first visible row already IS the member row. fn verb_member_indicator_row(slot_top: u16, verb_expanded: bool, header_clipped: bool) -> u16 { slot_top + u16::from(verb_expanded && !header_clipped) } /// Replace the bullet character on `entry_y` (a screen row) with the /// configured expandable indicator (e.g. `›`) when the entry is foldable /// and at its minimum fold mode. With `point_down` the indicator is the /// down variant (`⌄`) — the hover pass uses it on an expanded verb-group /// header row to advertise that the header collapses the group. /// /// Shared between the selected-entry post-pass and the hover post-pass so /// the chevron behaves identically in both states — only the bg differs. /// /// No-op when `expandable_indicator` is disabled, the entry isn't /// foldable, the entry isn't collapsed (or running + at min fold mode for /// streaming blocks like `Execute`/`Thinking`), or the block has no /// bullet. fn paint_expandable_indicator( buf: &mut Buffer, content_area: Rect, entry_y: u16, appearance: &crate::appearance::AppearanceConfig, entry: &ScrollbackEntry, point_down: bool, ) { let display_cfg = &appearance.scrollback.display; if !display_cfg.expandable_indicator { return; } if !entry.block.is_foldable() { return; } let at_min_fold = entry.display_mode == DisplayMode::Collapsed || (display_cfg.expandable_indicator_running && entry.is_running && entry.display_mode == entry.block.collapse_mode(true)); if !at_min_fold { return; } if !entry.block.has_bullet(&entry.context(0, appearance, None)) { return; } // The bullet sits at the start of the entry's content area on the // first row. `entry_y` is that first row in screen coords. Compute // the bullet column from the horizontal layout for a 1-row strip // anchored at `entry_y`. let layout_cfg = &appearance.scrollback.layout; let entry_layout = HorizontalLayout::new( Rect::new(content_area.x, entry_y, content_area.width, 1), layout_cfg, ); let bullet_x = entry_layout.content.x; if entry_y >= content_area.y && entry_y < content_area.y + content_area.height && let Some(cell) = buf.cell_mut((bullet_x, entry_y)) { // The down variant is fixed (not the configured char): it exists to // contrast with the right-pointing state, and `chevron_down` matches // `›`'s weight with its own ConHost fallback. let ch = if point_down { crate::glyphs::chevron_down().chars().next().unwrap_or('v') } else { display_cfg .expandable_indicator_char .chars() .next() .unwrap_or('›') }; cell.set_char(ch); } } #[cfg(test)] mod tests { use super::verb_member_indicator_row; // Pins the "caret offset ignores clipping" fix: the member caret // sits one row below the slot top only while the header row is visible. #[test] fn verb_member_indicator_row_tracks_header_clipping() { // Plain rows: caret on the slot's first row, clipped or not. assert_eq!(verb_member_indicator_row(4, false, false), 4); assert_eq!(verb_member_indicator_row(4, false, true), 4); // Expanded verb slot with the header visible: member row is slot+1. assert_eq!(verb_member_indicator_row(4, true, false), 5); // Header top-clipped off-screen: the first visible row IS member 0. assert_eq!(verb_member_indicator_row(4, true, true), 4); } }