//! EntryRenderer - renders a ScrollbackEntry using composed wrappers. use std::path::Path; use ratatui::buffer::Buffer; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::Style; use crate::appearance::AppearanceConfig; use crate::render::color::blend_color; use crate::render::{Renderable, SafeBuf}; use crate::scrollback::BlockOutput; use crate::scrollback::block::{BlockContent, RenderBlock}; use crate::scrollback::entry::ScrollbackEntry; use crate::scrollback::layout::HorizontalLayout; use crate::scrollback::types::{AccentStyle, BlockBackground, DisplayMode, Selectable}; use crate::theme::{self, Theme}; /// Animation speed for running blocks (radians per tick). /// ~0.15 gives a nice smooth wave that travels the block in ~40 ticks. const WAVE_SPEED: f32 = 0.15; pub struct EntryRenderer<'a> { entry: &'a ScrollbackEntry, theme: &'a Theme, appearance: AppearanceConfig, tick: u64, /// Number of rows to skip from the top of the entry. /// /// When non-zero, the renderer acts as if the entry starts `skip_rows` /// rows lower — it omits that many top rows (vpad, then content lines) /// and renders the remainder into `area`. This eliminates the need for /// scratch-buffer rendering of partially-visible entries. skip_rows: u16, /// Whether this entry's block is groupable (participates in dense groups). /// When true AND display_mode == Collapsed, the accent char switches from /// `┃` to the collapsed accent char (e.g., `❙`) with dimmed color. groupable: bool, /// Whether this entry is currently selected in the scrollback. is_selected: bool, /// Mouse position for timestamp hover detection. mouse_pos: Option<(u16, u16)>, /// When non-zero, this entry renders as a group truncation header /// ("╶╶ N more") instead of its normal block content. group_header_count: u16, /// When true, this is a collapse header for an expanded group /// ("▾ N tool calls" instead of "╶╶ N more"). group_collapse_header: bool, /// Aggregated group-header label; when set, group-header rows render it /// instead of the plain "N more" / "N tool calls" text. The variant /// picks the chrome: verb-run headers wear running/error accents from /// the run state, truncation headers keep the dimmed fold chrome. group_header_label: Option<&'a crate::scrollback::state::verb_group::GroupHeaderLabel>, /// When true, suppress the block's background band (force /// `BlockBackground::None`) and per-line "panel" bands /// ([`BlockLine::background_is_panel`] — tool result preview boxes) so the /// entry blends with the terminal's own background. Used by minimal mode, /// which prints into native scrollback where a fixed-color band (e.g. the /// user-message `bg_light` or a tool preview's `bg_dark`) clashes with the /// user's real terminal background. Semantic per-line backgrounds /// (code-block syntax shading, diff insert/delete rows) and the accent /// column are unaffected. flat_background: bool, /// When true, suppress the left accent bar and **reclaim its column** for /// content (chrome width drops by [`HorizontalLayout::ACCENT`]). Used by /// minimal mode for a cleaner, flush-left look — the per-block `◆`/bullet /// marker still reads the boundary between blocks, so the vertical accent /// lines are just visual noise there. Paired with zeroed /// `block_pad_{left,right}` in minimal's `committed_appearance`, content /// starts at column 0 (aligned with the welcome card). hide_accent: bool, /// Session/worktree cwd (`AgentSession.cwd`) for Expanded tool paths. cwd: Option<&'a Path>, } impl<'a> EntryRenderer<'a> { pub fn new(entry: &'a ScrollbackEntry, theme: &'a Theme) -> Self { Self { entry, theme, appearance: AppearanceConfig::default(), tick: 0, skip_rows: 0, groupable: false, is_selected: false, mouse_pos: None, group_header_count: 0, group_collapse_header: false, group_header_label: None, flat_background: false, hide_accent: false, cwd: None, } } pub fn with_cwd(mut self, cwd: Option<&'a Path>) -> Self { self.cwd = cwd; self } /// Suppress the block background band so the entry blends with the /// terminal's own background (minimal mode). See [`Self::flat_background`]. pub fn with_flat_background(mut self, flat: bool) -> Self { self.flat_background = flat; self } /// Suppress the left accent bar and reclaim its column (minimal mode's /// flush-left look). See [`Self::hide_accent`]. pub fn with_hide_accent(mut self, hide: bool) -> Self { self.hide_accent = hide; self } /// Background to paint where the block itself has none (accent column, /// gutter, bullets). In flat mode this is `Color::Reset` — the terminal's /// own default background — so the entry inherits terminal transparency /// instead of an opaque `bg_base` strip; otherwise it is `bg_base`. fn fallback_bg(&self) -> ratatui::style::Color { if self.flat_background { ratatui::style::Color::Reset } else { self.theme.bg_base } } pub fn with_appearance(mut self, appearance: AppearanceConfig) -> Self { self.appearance = appearance; self } pub fn with_tick(mut self, tick: u64) -> Self { self.tick = tick; self } /// Skip the first `n` rows of the entry when rendering. /// /// The skipped rows consume vpad first, then content lines. This allows /// partially-visible entries to be rendered directly into the output buffer /// without a scratch buffer intermediate. pub fn with_skip_rows(mut self, n: u16) -> Self { self.skip_rows = n; self } /// Mark this entry as groupable for accent rendering. /// /// When groupable AND collapsed, the accent char switches from `┃` to the /// collapsed accent char (e.g., `❙`) with dimmed color. pub fn with_groupable(mut self, groupable: bool) -> Self { self.groupable = groupable; self } /// Mark this entry as selected in the scrollback. pub fn with_selected(mut self, selected: bool) -> Self { self.is_selected = selected; self } /// Set the mouse position for timestamp hover detection. pub fn with_mouse_pos(mut self, pos: Option<(u16, u16)>) -> Self { self.mouse_pos = pos; self } /// Set the group header count for group truncation rendering. /// /// When non-zero, the renderer draws a compact "╶╶ N more" line /// instead of the block's normal content. pub fn with_group_header_count(mut self, count: u16) -> Self { self.group_header_count = count; self } pub fn with_group_collapse_header(mut self, collapse: bool) -> Self { self.group_collapse_header = collapse; self } /// Set the aggregated label for a group-header row (either fold family). pub fn with_group_header_label( mut self, label: Option<&'a crate::scrollback::state::verb_group::GroupHeaderLabel>, ) -> Self { self.group_header_label = label; self } /// Render a compact "╶╶ N more" group header line. /// /// Uses the collapsed accent char for the accent column, and renders the /// header text with dimmed styling to visually separate it from real entries. /// Avoids the full `self.accent()` path (which allocates a `BlockContext`) /// by reading the accent color directly from the theme. fn render_group_header(&self, area: Rect, buf: &mut Buffer) { use crate::scrollback::state::verb_group::GroupHeaderLabel; let layout_cfg = &self.appearance.scrollback.layout; let accent_w = if self.hide_accent { 0 } else { HorizontalLayout::ACCENT }; let [accent_area, _left_pad, content_area, _right_pad] = Layout::horizontal([ Constraint::Length(accent_w), Constraint::Length(layout_cfg.block_pad_left), Constraint::Min(1), Constraint::Length(layout_cfg.block_pad_right), ]) .areas(area); let display_cfg = &self.appearance.scrollback.display; let bg = self.theme.bg_base; // Verb-group header: aggregated "Verb N noun" label with run-state // accent (error > running wave > dimmed tool accent). The diamond // shares the accent color, so an active group's glyph animates with // the same wave as a running tool row's bullet. if let Some(GroupHeaderLabel::VerbRun(vg)) = self.group_header_label { let glyph_color = if vg.failed { let style = Style::default().fg(self.theme.accent_error); buf.set_string_safe( accent_area.x, accent_area.y, crate::glyphs::accent_bar(), style, ); self.theme.accent_error } else if vg.running { let brightness = theme::wave_brightness( self.tick, self.skip_rows, self.appearance.animation.wave_rows, WAVE_SPEED, ); let color = blend_color(bg, self.theme.accent_tool, brightness) .unwrap_or(self.theme.accent_tool); buf.set_string_safe( accent_area.x, accent_area.y, crate::glyphs::accent_bar(), Style::default().fg(color), ); color } else { let dimmed = blend_color(bg, self.theme.accent_tool, display_cfg.dim_accent) .unwrap_or(self.theme.accent_tool); buf.set_string_safe( accent_area.x, accent_area.y, &display_cfg.collapsed_accent_char, Style::default().fg(dimmed), ); self.theme.gray }; // Diamond chrome in BOTH states — same family as the "N more" // headers. The selection caret (the expandable indicator in // scrollback_pane.rs) overdraws the diamond on the selected row // and flips `›`/`⌄` with the group's fold state. let mut spans = vec![ratatui::text::Span::styled( group_header_chrome_prefix(), Style::default().fg(glyph_color), )]; spans.extend(vg.line.spans.iter().cloned()); let line = ratatui::text::Line::from(spans); buf.set_line_safe(content_area.x, content_area.y, &line, content_area.width); return; } // Render dimmed accent char — use theme.accent_tool directly instead of // going through self.accent() which creates a full BlockContext. let accent_color = self.theme.accent_tool; let dimmed = blend_color(bg, accent_color, display_cfg.dim_accent).unwrap_or(accent_color); let style = Style::default().fg(dimmed); buf.set_string_safe( accent_area.x, accent_area.y, &display_cfg.collapsed_accent_char, style, ); // Render header: ◈ (dimmed) + text (brighter, stands out). The // aggregated label describes the hidden rows through the shared // bucket vocabulary ("Ran 6 commands"); when the caller supplied // none (the render loop owns the reasons) the plain count remains. let n = self.group_header_count; let diamond_style = Style::default().fg(self.theme.gray); let text_style = Style::default() .fg(self.theme.gray_bright) .add_modifier(ratatui::style::Modifier::BOLD); let mut spans = vec![ratatui::text::Span::styled( group_header_chrome_prefix(), diamond_style, )]; // The VerbRun variant returned above, so only a truncation label can // reach this row's span assembly. if let Some(GroupHeaderLabel::Truncation(label)) = self.group_header_label { spans.extend(label.line.spans.iter().cloned()); } else { let label = if self.group_collapse_header { format!("{n} tool calls & thoughts") } else { format!("{n} more") }; spans.push(ratatui::text::Span::styled(label, text_style)); } let line = ratatui::text::Line::from(spans); buf.set_line_safe(content_area.x, content_area.y, &line, content_area.width); } /// Get the chrome width (accent + padding) for this renderer's appearance. /// /// When [`Self::hide_accent`] is set the accent column is reclaimed, so /// chrome is just the block pads (typically zeroed in minimal mode). pub fn chrome_width(&self) -> u16 { let pads = self.appearance.scrollback.layout.block_pad_left + self.appearance.scrollback.layout.block_pad_right; if self.hide_accent { pads } else { HorizontalLayout::ACCENT + pads } } /// Legacy fixed chrome-width estimate for callers with no appearance to /// borrow (off-screen mermaid sizing); the live value is `chrome_width()`. pub const CHROME_WIDTH: u16 = 1 + 2 + 1; // accent + left_pad + right_pad (legacy) /// Whether this entry should display a timestamp on the first content line. /// /// Timestamps are shown for user and agent messages (including /btw responses /// and mid-turn interjections) but NOT for thinking traces, tool calls, or /// system messages. fn should_show_timestamp(&self) -> bool { matches!( self.entry.block, RenderBlock::UserPrompt(_) | RenderBlock::AgentMessage(_) | RenderBlock::Btw(_) ) } /// Width reserved for the timestamp on the right side of content lines. /// /// When > 0, content is wrapped at `content_width - reserved` so text /// never collides with the timestamp overlay. fn timestamp_reserved(&self) -> u16 { if self.appearance.show_timestamps && self.should_show_timestamp() { 10 // max short format: " 12:30 PM" } else { 0 } } fn accent(&self, content_width: u16) -> Option { let mut ctx = self .entry .context(content_width, &self.appearance, self.cwd); ctx.is_selected = self.is_selected; self.entry.block.accent(&ctx) } /// Thinking entries take no rows when the Appearance toggle is off. fn thinking_hidden(&self) -> bool { self.entry .is_hidden_thinking(crate::appearance::cache::load_show_thinking_blocks()) } /// Compute height as if displayed in Truncated mode. /// /// This avoids cloning the entry just to compute truncated height. /// Used by layout cache to precompute sticky header heights. /// /// Goes through the entry's truncated-height cache so repeated layout /// rebuilds don't re-run `block.output()` (which is expensive — full /// syntect highlighting for Edit blocks, full word-wrap for Markdown). pub fn compute_truncated_height(&self, width: u16) -> u16 { if self.thinking_hidden() { return 0; } let content_width = width .saturating_sub(self.chrome_width()) .saturating_sub(self.timestamp_reserved()); self.entry .ensure_truncated_height_cached(content_width, &self.appearance, self.cwd) } /// Extra rows to reserve for inline media preview (images/video poster). /// Returns 0 on non-graphics terminals or when the block has no media. fn inline_media_rows(&self, content_width: u16) -> u16 { use crate::terminal::image::scrollback_inline_overlay_active; if !scrollback_inline_overlay_active() { return 0; } let Some(media) = self.entry.block.inline_media() else { return 0; }; // Shared with `inline_media_placements` so the reserved rows match the // painted placement exactly. let (_image_rows, total_rows) = crate::inline_media_ffmpeg::inline_media_reserved_rows(&media, content_width); total_rows } /// Cheap height ESTIMATE that avoids a markdown render / word-wrap. /// /// Mirrors `desired_height` but derives the content line count from the block's /// raw source text (`searchable_text`) instead of laying it out. Lets the layout /// cache size off-screen entries on a bulk load (`kigi -r`) without word-wrapping /// and markdown-rendering every entry (O(history)). On-screen entries get their /// EXACT `desired_height`, so visible content is never estimated. pub fn estimate_height(&self, width: u16) -> u16 { if self.thinking_hidden() { return 0; } let content_width = width .saturating_sub(self.chrome_width()) .saturating_sub(self.timestamp_reserved()); let content_lines = self.estimate_content_lines(content_width); // `inline_media_rows` (in `assemble_height`) covers trailing tool media. // `estimate_extra_rows` adds the Mermaid treatment rows (one affordance // row / fallback caption per diagram) that live inside `output()` and are // invisible to this source-based estimate, so it never under-reserves. self.assemble_height(content_width, content_lines) .saturating_add(self.entry.block.estimate_extra_rows()) } /// Estimate the rendered content-line count without laying the block out. /// /// Memoized per content width on the entry (cleared by `invalidate_cache`) /// so a full layout rebuild at the same width — e.g. the rebuild after a /// fold / group-expand — doesn't re-clone every entry's source text. fn estimate_content_lines(&self, content_width: u16) -> u16 { if let Some(lines) = self.entry.cached_estimate_lines(content_width) { return lines; } // Collapsed / Truncated foldable entries render a compact ~1-line header, // NOT their (often huge) hidden body. Use the ENTRY-level foldability // (`block.is_foldable()` OR attached hooks), matching the fold path, so a // hook-only-foldable collapsed entry isn't over-counted. let lines = if self.entry.display_mode != DisplayMode::Expanded && self.entry.is_foldable() { 1 } else { match self.entry.block.searchable_text() { Some(text) => estimate_wrapped_line_count(&text, content_width), None => 1, } }; self.entry.store_estimate_lines(content_width, lines); lines } /// Combine a content-line count with this entry's vpad + inline-media rows. /// Saturating throughout so a multi-MB block whose line count hits the u16 /// ceiling can't overflow and corrupt `virtual_y` / `total_height`. Shared by /// `desired_height` and `estimate_height` so the assembly stays canonical. fn assemble_height(&self, content_width: u16, content_lines: u16) -> u16 { let ctx = self .entry .context(content_width, &self.appearance, self.cwd); let vpad: u16 = if self.entry.block.has_vpad(&ctx) { 2 } else { 0 }; content_lines .saturating_add(vpad) .saturating_add(self.inline_media_rows(content_width)) } /// Rendered-row offset (from the entry top, including any top vpad row) of /// each logical (newline-delimited) line's start, plus the entry's final /// rendered content row, at viewport `width`. /// /// The SINGLE source of truth for the logical-line ↔ rendered-row mapping, so /// the forward ([`rendered_row_of_logical_line`]) and inverse /// ([`logical_line_of_rendered_row`]) helpers below derive from one predicate /// and can't drift. A logical-line start is a SELECTABLE hard-break row — /// matching the search index's `plain_text_from_output`; `Selectable::None` /// decoration rows (e.g. the Thinking header/blank) and soft-wrap /// continuations (`joiner.is_some()`) are not starts but still occupy rows /// (so the full-enumeration index is used). The returned starts are strictly /// ascending. Inline-media rows are not accounted for. /// /// Returns `(starts, last_content_row)`. `last_content_row` is the entry's /// final rendered row, used to bound the last logical line (which has no /// following start) and to clamp out-of-range lookups. pub(crate) fn logical_line_start_rows(&self, width: u16) -> (Vec, u16) { let content_width = width .saturating_sub(self.chrome_width()) .saturating_sub(self.timestamp_reserved()); // Compute vpad and populate the cache before borrowing the cached output: // `ensure_cached` takes the RefCell mutably on a miss, so the `Ref` from // `cached_output_ref` must come after it. let ctx = self .entry .context(content_width, &self.appearance, self.cwd); let vpad_top: u16 = if self.entry.block.has_vpad(&ctx) { 1 } else { 0 }; self.entry .ensure_cached(content_width, &self.appearance, false, self.cwd); let output = self.entry.cached_output_ref(); let starts = output .lines .iter() .enumerate() .filter(|(_, line)| { !matches!(line.selectable, Selectable::None) && line.joiner.is_none() }) .map(|(idx, _)| vpad_top.saturating_add(u16::try_from(idx).unwrap_or(u16::MAX))) .collect(); let last_content_row = vpad_top.saturating_add( u16::try_from(output.lines.len().saturating_sub(1)).unwrap_or(u16::MAX), ); (starts, last_content_row) } /// Rendered-row offset from the entry's top (including any top vpad row) at /// which the search index's `logical_line`-th logical (newline-delimited) /// line begins, at viewport `width`. /// /// EXACT for blocks whose searchable text mirrors their selectable rendered /// lines (plain source blocks, markdown/thinking bodies); a best-effort /// estimate for field-joined source (Subagent/BgTask/etc.), kept on /// screen by the caller's entry-height clamp. Past the last logical line, /// clamps to the final content row. pub fn rendered_row_of_logical_line(&self, width: u16, logical_line: usize) -> u16 { let (starts, last_content_row) = self.logical_line_start_rows(width); starts .get(logical_line) .copied() .unwrap_or(last_content_row) } /// Inverse of [`rendered_row_of_logical_line`]: the logical (newline- /// delimited) line index whose start lies at or before rendered-row offset /// `row` (from the entry top, including vpad), at viewport `width`. /// /// A display-row offset into a word-wrapped entry is not stable across a /// width change, but the logical line it sits on is — so scroll re-anchoring /// across a resize captures the logical line with this, then re-resolves its /// row at the new width via `rendered_row_of_logical_line`. Shares /// `logical_line_start_rows` with that method so the two provably round-trip. pub fn logical_line_of_rendered_row(&self, width: u16, row: u16) -> usize { let (starts, _) = self.logical_line_start_rows(width); // `starts` is ascending: the count of starts at or before `row`, minus 1, // is the 0-based index of the logical line containing `row`. starts .partition_point(|&start| start <= row) .saturating_sub(1) } } /// Diamond chrome prefix every group header draws before its text — verb-run /// labels, truncation labels, and plain counts alike, in both fold states. /// Selection geometry for labeled headers derives from this same string (see /// [`group_header_chrome_prefix_width`]) so render and hitbox can't drift. pub(crate) fn group_header_chrome_prefix() -> String { format!("{} ", crate::glyphs::diamond_dotted()) } /// Display width of [`group_header_chrome_prefix`]. pub(crate) fn group_header_chrome_prefix_width() -> u16 { unicode_width::UnicodeWidthStr::width(group_header_chrome_prefix().as_str()) as u16 } /// Fill a region with background-styled spaces so the frame owns these cells; /// the cell diff won't repaint an unowned cell, so a stale glyph would persist. fn fill_bg_spaces(buf: &mut Buffer, rect: Rect, bg: ratatui::style::Color) { let style = Style::default().bg(bg); for y in rect.y..rect.bottom() { for x in rect.x..rect.right() { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_char(' '); cell.set_style(style); } } } } /// Estimate the wrapped line count for raw `text` at a given content width. /// /// Uses DISPLAY width (`unicode_width`), not byte length. A deliberately cheap /// approximation: it ignores word boundaries and works from RAW source, so it may /// be larger or smaller than the exact rendered height. Correctness never relies /// on it — on-screen entries are always measured exactly. fn estimate_wrapped_line_count(text: &str, content_width: u16) -> u16 { let cw = content_width.max(1) as usize; // Renderers drop a single trailing newline; match that so trailing-`\n` // source doesn't estimate one row too many. let text = text.strip_suffix('\n').unwrap_or(text); let mut total: usize = 0; for line in text.split('\n') { let display_width = unicode_width::UnicodeWidthStr::width(line); total += if display_width == 0 { 1 } else { display_width.div_ceil(cw) }; if total >= u16::MAX as usize { return u16::MAX; } } total.max(1) as u16 } impl Renderable for EntryRenderer<'_> { fn desired_height(&self, width: u16) -> u16 { if self.thinking_hidden() { return 0; } let content_width = width .saturating_sub(self.chrome_width()) .saturating_sub(self.timestamp_reserved()); // Use cached output for height calculation. The is_selected flag only // affects styling (e.g., UserPrompt prefix color), not line count, so // the non-selected cached output gives the correct height. self.entry .ensure_cached(content_width, &self.appearance, false, self.cwd); // Clamp the line count: a pathologically large block could exceed u16. let content_lines = self.entry.cached_output_ref().len().min(u16::MAX as usize) as u16; self.assemble_height(content_width, content_lines) } fn render(&self, area: Rect, buf: &mut Buffer) { let chrome = self.chrome_width(); if area.width < chrome + 1 || area.height == 0 || self.thinking_hidden() { return; } // Expand header ("╶╶ N more"): replaces block content entirely. // Only for truncated (non-expanded) groups. if self.group_header_count > 0 && !self.group_collapse_header { self.render_group_header(area, buf); return; } // Collapse header ("▾ N tool calls & thoughts"): standalone header // entry (height=1) that replaces the first group entry's content. // The remaining group entries keep their normal heights, giving the // header its own selectable index with independent interaction. let (area, skip_rows) = if self.group_collapse_header { if self.skip_rows == 0 { // Header is visible — render it in the first row let header_area = Rect::new(area.x, area.y, area.width, 1); self.render_group_header(header_area, buf); let remaining_height = area.height.saturating_sub(1); if remaining_height == 0 { return; } let remaining = Rect::new(area.x, area.y + 1, area.width, remaining_height); (remaining, 0u16) } else { // Header is scrolled off — reduce skip_rows by 1 for content (area, self.skip_rows - 1) } } else { (area, self.skip_rows) }; let layout_cfg = &self.appearance.scrollback.layout; // Minimal (`hide_accent`): reclaim the accent column so content is // flush-left. Fullscreen keeps the 1-col gutter even when a block has // no painted accent (so columns stay aligned across entry types). let accent_w = if self.hide_accent { 0 } else { HorizontalLayout::ACCENT }; let [accent_area, left_pad, content_area, right_pad] = Layout::horizontal([ Constraint::Length(accent_w), Constraint::Length(layout_cfg.block_pad_left), Constraint::Min(1), Constraint::Length(layout_cfg.block_pad_right), ]) .areas(area); // Build context directly — avoid calling effective_output() here since // we only need the context for background/accent decisions, not the output. // This eliminates a full block.output() call (including syntax highlighting // for edit blocks) that was previously thrown away. let mut ctx = self .entry .context(content_area.width, &self.appearance, self.cwd); ctx.is_selected = self.is_selected; // Minimal mode blends committed/tail blocks with the real terminal // background; suppress the block's own band (keeps accents + per-line // code shading). let bg = if self.flat_background { BlockBackground::None } else { self.entry.block.background(&ctx) }; // Fill padding with background if block has a background let bg_color = match bg { BlockBackground::None => None, BlockBackground::Light => Some(self.theme.bg_light), BlockBackground::Dark => Some(self.theme.bg_dark), }; if let Some(bg_color) = bg_color { let bg_style = Style::default().bg(bg_color); // Fill the entire visible area (already accounts for skip_rows via area sizing) let full_start_y = area.y; let full_end_y = area.y + area.height; // Fill accent area if block wants accent background if self.entry.block.accent_background(&ctx) { for y in full_start_y..full_end_y { for x in accent_area.x..accent_area.x + accent_area.width { if let Some(cell) = buf.cell_mut((x, y)) { cell.set_style(bg_style); } } } } // Fill left padding for y in full_start_y..full_end_y { 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); } } } // Fill content area for y in full_start_y..full_end_y { 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); } } } // Fill right padding for y in full_start_y..full_end_y { 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 accent line based on accent style. // When skip_rows > 0, offset the wave phase so animation stays correct. // // Groupable + collapsed blocks use the collapsed accent char (e.g., "❙") // with dimmed color to prevent adjacent accents from merging visually. // Check if this entry recently finished and should flash its accent. let recently_finished = !self.entry.is_running && self.entry.finished_at.is_some_and(|t| { t.elapsed().as_millis() < crate::scrollback::state::FINISH_FLASH_DURATION_MS as u128 }) && matches!( self.entry.block, RenderBlock::ToolCall(_) | RenderBlock::Thinking(_) ); let accent = if self.hide_accent { // Minimal mode: no left accent bar; column reclaimed (accent_w = 0). None } else if recently_finished { // Flash: show a static accent in a visible color. // Tool calls like Read/Search/Edit normally have no accent, // so fall back to accent_success (green) for the flash. // Thinking blocks use their natural purple accent. let color = self .entry .block .accent_color() .unwrap_or(self.theme.accent_success); Some(AccentStyle::static_color(color)) } else { self.accent(content_area.width) }; let has_hook_lines = self .entry .hook_data .as_ref() .is_some_and(|hd| hd.has_content()); let use_collapsed_accent = self.groupable && self.entry.display_mode == DisplayMode::Collapsed && !has_hook_lines; let display_cfg = &self.appearance.scrollback.display; if accent.is_none() { // No accent: clear the accent column so stale content from // previous frames doesn't bleed through. Use the block's bg // (if any) so the column matches the rest of the entry. fill_bg_spaces(buf, accent_area, bg_color.unwrap_or(self.fallback_bg())); } if let Some(accent_style) = accent { let color = accent_style.color; let is_pending = self.entry.is_pending_user_input; if is_pending && accent_style.animated { // Pending user input: freeze the running wave. A solid // accent at full color reads as "paused on you" without // the loading-spinner motion. let style = Style::default().fg(color); for y in accent_area.y..accent_area.y + accent_area.height { buf.set_string_safe(accent_area.x, y, crate::glyphs::accent_bar(), style); } } else if accent_style.animated { // Animated accents: wave effect (running blocks) let bg = bg_color.unwrap_or(self.fallback_bg()); let wave_rows = self.appearance.animation.wave_rows; for row in 0..accent_area.height { let y = accent_area.y + row; let logical_row = skip_rows + row; let brightness = theme::wave_brightness(self.tick, logical_row, wave_rows, WAVE_SPEED); let animated_color = blend_color(bg, color, brightness).unwrap_or(color); let style = Style::default().fg(animated_color); buf.set_string_safe(accent_area.x, y, crate::glyphs::accent_bar(), style); } } else if use_collapsed_accent && !self.is_selected { // Dimmed collapsed accent: thin char + blended color. // When the entry is selected, fall through to the static // full-color branch so the selection reads as undimmed. let bg = bg_color.unwrap_or(self.fallback_bg()); let dimmed = blend_color(bg, color, display_cfg.dim_accent).unwrap_or(color); let style = Style::default().fg(dimmed); for y in accent_area.y..accent_area.y + accent_area.height { buf.set_string_safe( accent_area.x, y, &display_cfg.collapsed_accent_char, style, ); } } else { // Static accent: full color let style = Style::default().fg(color); for y in accent_area.y..accent_area.y + accent_area.height { buf.set_string_safe(accent_area.x, y, crate::glyphs::accent_bar(), style); } } } // Render content using cache (keyed on is_selected for blocks like // UserPrompt that adjust styling based on selection state). // When timestamps are enabled, wrap content at a narrower width so // text never collides with the right-aligned timestamp. let ts_reserved = self.timestamp_reserved(); let text_width = content_area.width.saturating_sub(ts_reserved); self.entry .ensure_cached(text_width, &self.appearance, self.is_selected, self.cwd); let cached_ref = self.entry.cached_output_ref(); let output: &BlockOutput = &cached_ref; let has_vpad = self.entry.block.has_vpad(&ctx); // Determine how many rows of vpad/content to skip. // Layout is: [vpad_top?] [content lines...] [vpad_bottom?] let vpad_top = if has_vpad { 1u16 } else { 0 }; let skip_remaining = skip_rows; // Skip vpad_top (0 or 1 row) let vpad_top_visible = if skip_remaining < vpad_top { // Partial skip into vpad — vpad is still visible // (vpad is 0 or 1 row, so this means skip_rows == 0) true } else { false }; let content_skip = skip_remaining.saturating_sub(vpad_top); let mut row = content_area.y; let max_row = content_area.y + content_area.height; // Top vpad (only if not skipped) if vpad_top_visible && row < max_row { row += 1; } // Own the timestamp gutter per row (no-bg blocks skip the full-area fill) // so a wide glyph can't strand a ghost; per-row bg keeps code blocks whole. let own_gutter = ts_reserved > 0 && bg_color.is_none() && content_area.width > ts_reserved; // Content lines — skip the first `content_skip` lines for line in output.lines.iter().skip(content_skip as usize) { if row >= max_row { break; } // Apply line-specific background if set. Decorative panel bands // (tool result previews) are dropped in flat mode so they blend // with the terminal's own background (minimal mode); semantic // shading (diff rows, code-block fill) always paints. let line_bg = if self.flat_background && line.background_is_panel { None } else { line.background }; if let Some(line_bg) = line_bg { let bg_x = content_area.x + line.bg_start_col; let bg_width = content_area.width.saturating_sub(line.bg_start_col); if bg_width > 0 { let line_rect = Rect::new(bg_x, row, bg_width, 1); buf.set_style(line_rect, Style::default().bg(line_bg)); } } buf.set_line_safe(content_area.x, row, &line.content, content_area.width); if own_gutter { let gutter = Rect::new(content_area.x + text_width, row, ts_reserved, 1); fill_bg_spaces(buf, gutter, line_bg.unwrap_or(self.fallback_bg())); } row += 1; } // Overlay timestamp on the first content line for message blocks. // Short format (h:mm AM/PM) by default; expands to full format // (HH:mm:ss | MMM DD) when the mouse hovers over the timestamp area. // Gated on appearance.show_timestamps (toggled via /timestamps). if self.appearance.show_timestamps && content_skip == 0 && !output.is_empty() && self.should_show_timestamp() && let Some(ts) = self.entry.created_at { let first_content_y = content_area.y + if vpad_top_visible { 1 } else { 0 }; // Check if mouse is hovering the timestamp zone (rightmost 10 cols // of the first content row). let ts_hovered = self.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 < max_row { let ts_x = content_area.x + content_area.width - ts_width; let ts_style = Style::default().fg(self.theme.gray); buf.set_string_safe(ts_x, first_content_y, &ts_str, ts_style); } } // Post-pass: adjust bullet color based on block state. // // Three cases (in priority order): // 1. Pending user input (permission / question) → keep the bullet // glyph but force a static accent color, skipping the running // wave so the entry reads as "paused on you", not "loading". // 2. Running block with animated bullet → wave animation on bullet char // 3. Collapsed groupable block with colored bullet → dim the bullet color // // The bullet is the first character on the first content row. if skip_rows == 0 && self.entry.block.has_bullet(&ctx) { let bullet_style = self.entry.block.bullet(&ctx); let bullet_y = content_area.y + if has_vpad { 1 } else { 0 }; if bullet_y >= max_row { // bullet not visible — skip post-pass } else if self.entry.is_pending_user_input { // Pending user input: leave the bullet glyph alone, just // freeze its color at the block's bullet color (falling // back to `accent_user` for blocks that supply no bullet // style, e.g. Collapsed tool calls — they otherwise render // in default gray and lose the cue entirely). The fallback // intentionally matches the turn-status diamond and the // drain-blocked diamond so every "your turn" cue reads in // the same hue across the scrollback and status line. let color = bullet_style .map(|s| s.color) .unwrap_or(self.theme.accent_user); if let Some(cell) = buf.cell_mut((content_area.x, bullet_y)) { cell.fg = color; } } else if let Some(style) = bullet_style { if style.animated { // Animated bullet: wave effect synced with accent let bg = bg_color.unwrap_or(self.fallback_bg()); let wave_rows = self.appearance.animation.wave_rows; let brightness = theme::wave_brightness(self.tick, 0, wave_rows, WAVE_SPEED); let animated_color = blend_color(bg, style.color, brightness).unwrap_or(style.color); if let Some(cell) = buf.cell_mut((content_area.x, bullet_y)) { cell.fg = animated_color; } } else if self.groupable && self.entry.display_mode == DisplayMode::Collapsed && !self.is_selected { // Collapsed groupable with colored bullet: dim it. // When selected, keep the bullet at its original full // color so the selection reads as undimmed. let bg = bg_color.unwrap_or(self.fallback_bg()); let dimmed = blend_color(bg, style.color, display_cfg.dim_accent).unwrap_or(style.color); if let Some(cell) = buf.cell_mut((content_area.x, bullet_y)) { cell.fg = dimmed; } } // Static + not collapsed+groupable: bullet already has correct color // from prepend_bullet(), no post-pass needed. } } } } #[cfg(test)] mod tests { use super::*; use crate::scrollback::RenderBlock; use crate::theme::cache::pin_theme; use pretty_assertions::assert_eq; use ratatui::style::Color; #[test] fn test_entry_renderer_height() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::stub("Hello", Color::Blue)); let renderer = EntryRenderer::new(&entry, &theme); // StubBlock: 1 line + 2 vpad = 3 // Width 80 - chrome(4) = 76 for content assert_eq!(renderer.desired_height(80), 3); } #[test] fn rendered_row_of_logical_line_follows_word_wrap() { let _theme = pin_theme(); let theme = Theme::current(); // First logical line is long enough to wrap into several rows at a // narrow width; the second logical line then starts well past row 1. let text = format!("{}\nsecond", "word ".repeat(40)); let entry = ScrollbackEntry::new(RenderBlock::user_prompt(text)); let renderer = EntryRenderer::new(&entry, &theme); // Narrow entry-area width (chrome is subtracted internally) forces wrap. let row0 = renderer.rendered_row_of_logical_line(30, 0); let row1 = renderer.rendered_row_of_logical_line(30, 1); // Line 0 starts on the first content row (after any top vpad row). assert!( row0 <= 1, "first logical line starts at the top content row" ); // Line 1 begins after every wrapped row of line 0, so it lands more than // one row below it — proof the mapping follows the word wrap. assert!( row1 > row0 + 1, "wrapped first line must push the second logical line past row {}", row0 + 1 ); } #[test] fn test_entry_renderer_layout() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::stub("Test", Color::Blue)); let renderer = EntryRenderer::new(&entry, &theme); // Area: 20 chars wide, 3 rows // Layout: accent(1) + left_pad(2) + content(16) + right_pad(1) = 20 let area = Rect::new(0, 0, 20, 3); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); // Accent at column 0 assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "┃"); assert_eq!(buf.cell((0, 1)).unwrap().symbol(), "┃"); assert_eq!(buf.cell((0, 2)).unwrap().symbol(), "┃"); // Left padding at columns 1-2 (empty space) assert_eq!(buf.cell((1, 1)).unwrap().symbol(), " "); assert_eq!(buf.cell((2, 1)).unwrap().symbol(), " "); // Content starts at column 3 // Row 0 = vpad (empty), row 1 = content "Test", row 2 = vpad assert_eq!(buf.cell((3, 1)).unwrap().symbol(), "T"); assert_eq!(buf.cell((4, 1)).unwrap().symbol(), "e"); assert_eq!(buf.cell((5, 1)).unwrap().symbol(), "s"); assert_eq!(buf.cell((6, 1)).unwrap().symbol(), "t"); } #[test] fn test_chrome_width_constant() { assert_eq!(EntryRenderer::CHROME_WIDTH, 4); // 1 + 2 + 1 } #[test] fn pending_user_input_keeps_diamond_bullet_with_static_color() { // While a tool is blocked on a permission / question we keep the // default Diamond bullet but freeze its color — no character swap // and no wave brightness animation. The bullet must read the same // glyph at every tick so the eye sees "paused on you", not the // running-wave loading cue. use crate::scrollback::blocks::tool::{OtherToolCallBlock, ToolCallBlock}; let theme = Theme::current(); let mut entry = ScrollbackEntry::running(RenderBlock::ToolCall(ToolCallBlock::Other( OtherToolCallBlock::new("ask_user_question", "ask user"), ))); entry.is_pending_user_input = true; let area = Rect::new(0, 0, 60, 3); let mut first_fg: Option = None; for tick in [0u64, 13, 27, 41, 55] { let mut buf = Buffer::empty(area); let renderer = EntryRenderer::new(&entry, &theme).with_tick(tick); renderer.render(area, &mut buf); // Default layout: accent(1) + left_pad(2) + content starts at 3. // Tool call header has no vpad, so bullet sits on row 0. let cell = buf.cell((3, 0)).unwrap(); assert_eq!( cell.symbol(), "◆", "pending tool must keep the Diamond bullet at tick {tick}" ); match first_fg { None => first_fg = Some(cell.fg), Some(prev) => assert_eq!( cell.fg, prev, "pending bullet color must be static across ticks (tick {tick})" ), } } } #[test] fn non_pending_running_tool_keeps_default_bullet() { // Sanity check: when is_pending_user_input is false we leave the // normal Diamond bullet alone so the running wave animation runs // on top of it as before. use crate::scrollback::blocks::tool::{OtherToolCallBlock, ToolCallBlock}; let theme = Theme::current(); let entry = ScrollbackEntry::running(RenderBlock::ToolCall(ToolCallBlock::Other( OtherToolCallBlock::new("ask_user_question", "ask user"), ))); // is_pending_user_input is false by default. let area = Rect::new(0, 0, 60, 3); let mut buf = Buffer::empty(area); let renderer = EntryRenderer::new(&entry, &theme).with_tick(7); renderer.render(area, &mut buf); assert_eq!(buf.cell((3, 0)).unwrap().symbol(), "◆"); } /// Collect the symbols from a row range in the buffer into a String. fn collect_row_symbols(buf: &Buffer, y: u16, x_start: u16, x_end: u16) -> String { (x_start..x_end) .filter_map(|x| buf.cell((x, y)).map(|c| c.symbol().to_string())) .collect() } /// The reserved timestamp gutter (rightmost `ts_reserved` content columns) /// for `width`, derived from the renderer geometry so tests self-adjust to /// the default layout padding instead of hard-coding column numbers. fn gutter_band(renderer: &EntryRenderer, width: u16) -> std::ops::Range { let content_right = width - renderer.appearance.scrollback.layout.block_pad_right; (content_right - renderer.timestamp_reserved())..content_right } /// Check that a right-aligned timestamp ending with "AM" or "PM" exists on a row. /// Only scans the rightmost 16 columns to avoid false positives from content text. fn has_ampm_timestamp(buf: &Buffer, y: u16, x_end: u16) -> bool { let x_start = x_end.saturating_sub(16); let text = collect_row_symbols(buf, y, x_start, x_end); text.contains("AM") || text.contains("PM") } #[test] fn test_timestamp_short_format_for_user_prompt() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::user_prompt("hello")); // Not selected → short format "h:mm AM/PM" let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); // UserPrompt has vpad=true, first content row is y=1. let expected = chrono::Local::now().format("%-I:%M %p").to_string(); let ts_width = expected.len() as u16; let ts_x = width - 2 - ts_width; let content_row = 1u16; let rendered = collect_row_symbols(&buf, content_row, ts_x, ts_x + ts_width); assert_eq!( rendered, expected, "Expected short timestamp '{expected}' at row {content_row}" ); } #[test] fn test_timestamp_short_format_for_agent_message() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::agent_message("hello")); let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); // AgentMessage has vpad=false, first content row is y=0. let expected = chrono::Local::now().format("%-I:%M %p").to_string(); let ts_width = expected.len() as u16; let ts_x = width - 2 - ts_width; let rendered = collect_row_symbols(&buf, 0, ts_x, ts_x + ts_width); assert_eq!( rendered, expected, "Expected short timestamp '{expected}' at row 0" ); } #[test] fn test_timestamp_expands_on_mouse_hover() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::agent_message("hello")); let width: u16 = 80; // AgentMessage has no vpad → first content row at y=0. // Hover the rightmost 10 cols of that row to trigger expansion. let hover_x = width - 2 - 5; // inside the timestamp zone let renderer = EntryRenderer::new(&entry, &theme).with_mouse_pos(Some((hover_x, 0))); let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); let expected = chrono::Local::now().format("%H:%M:%S | %b %d").to_string(); let ts_width = expected.len() as u16; let ts_x = width - 2 - ts_width; let rendered = collect_row_symbols(&buf, 0, ts_x, ts_x + ts_width); assert_eq!( rendered, expected, "Mouse-hovered timestamp should show expanded format '{expected}'" ); } #[test] fn test_timestamp_collapses_when_mouse_away() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::agent_message("hello")); let width: u16 = 80; // Render with mouse hovering timestamp → expanded let hover_x = width - 2 - 3; let renderer = EntryRenderer::new(&entry, &theme).with_mouse_pos(Some((hover_x, 0))); let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf_hover = Buffer::empty(area); renderer.render(area, &mut buf_hover); // Render with mouse far from timestamp → short let renderer = EntryRenderer::new(&entry, &theme).with_mouse_pos(Some((5, 0))); let mut buf_away = Buffer::empty(area); renderer.render(area, &mut buf_away); // Hovered should have pipe separator (expanded format) let row_hover = collect_row_symbols(&buf_hover, 0, 0, width); assert!( row_hover.contains('|'), "Hovered should show expanded format with '|'" ); // Away should have AM/PM but NO pipe let row_away = collect_row_symbols(&buf_away, 0, 0, width); assert!( has_ampm_timestamp(&buf_away, 0, width), "Non-hovered should show short AM/PM timestamp" ); assert!( !row_away.contains('|'), "Non-hovered should NOT show '|' separator" ); } #[test] fn test_no_timestamp_for_thinking_block() { crate::appearance::cache::set_show_thinking_blocks(true); let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::thinking("deep thoughts")); let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); assert!(height > 0); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); for y in 0..height { assert!( !has_ampm_timestamp(&buf, y, width), "Thinking block should not have a timestamp on row {y}" ); } } #[test] fn test_no_timestamp_for_tool_call() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::tool_call("Read", "src/main.rs", true)); let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); assert!(height > 0); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); for y in 0..height { assert!( !has_ampm_timestamp(&buf, y, width), "Tool call block should not have a timestamp on row {y}" ); } } #[test] fn test_should_show_timestamp_returns_correct_values() { use crate::scrollback::blocks::BtwBlock; let theme = Theme::current(); let width: u16 = 80; // Blocks that SHOULD show timestamps let positive_blocks = vec![ ("UserPrompt", RenderBlock::user_prompt("test"), 1u16), ("AgentMessage", RenderBlock::agent_message("test"), 0), ("Btw", RenderBlock::Btw(BtwBlock::new("q", "a")), 0), ]; for (name, block, content_row) in positive_blocks { let entry = ScrollbackEntry::new(block); let renderer = EntryRenderer::new(&entry, &theme); let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); assert!( has_ampm_timestamp(&buf, content_row, width), "{name} should show AM/PM timestamp on row {content_row}" ); } // Blocks that should NOT show timestamps let negative_blocks = vec![ ("Thinking", RenderBlock::thinking("think")), ("ToolCall", RenderBlock::tool_call("Read", "file.rs", true)), ("System", RenderBlock::system("sys msg")), ("Stub", RenderBlock::stub("stub", Color::Blue)), ]; for (name, block) in negative_blocks { let entry = ScrollbackEntry::new(block); let renderer = EntryRenderer::new(&entry, &theme); let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); let mut found = false; for y in 0..height { if has_ampm_timestamp(&buf, y, width) { found = true; break; } } assert!(!found, "{name} should NOT show timestamp"); } } #[test] fn test_timestamp_not_rendered_when_narrow() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::user_prompt("hi")); let renderer = EntryRenderer::new(&entry, &theme); // Short timestamp "h:mm AM" is 7-8 chars; code requires content_width > ts_width + 1. // Width 12: chrome(5 actual) leaves content_width = 7, just barely not enough // for 8-char timestamp + 1. Suppress. let width: u16 = 12; let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); for y in 0..height { assert!( !has_ampm_timestamp(&buf, y, width), "Narrow width should suppress timestamp on row {y}" ); } } // ── timestamp gutter ownership (ghost-glyph regression) ── #[test] fn gutter_cleared_on_non_first_content_row_without_background() { // Regression: nothing writes the timestamp gutter on rows past the // first, so a glyph stranded there (e.g. a wide table cell past // `text_width`) used to persist. Every content row must now clear it. let theme = Theme::current(); // Long enough to wrap into several content rows at width 80. let entry = ScrollbackEntry::new(RenderBlock::agent_message("word ".repeat(80))); let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); assert!(height >= 3, "need multiple content rows, got {height}"); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); // A column inside the gutter band (past `text_width`), so content never // writes it; only the gutter clear can. let ghost_x = gutter_band(&renderer, width).start + 2; buf.set_string(ghost_x, 1, "X", Style::default()); buf.set_string(ghost_x, 2, "X", Style::default()); renderer.render(area, &mut buf); // Without the fix these cells stay "X" (nothing repaints them). assert_eq!( buf.cell((ghost_x, 1)).unwrap().symbol(), " ", "gutter ghost on row 1 must be cleared" ); assert_eq!( buf.cell((ghost_x, 2)).unwrap().symbol(), " ", "gutter ghost on row 2 must be cleared" ); } #[test] fn gutter_clear_preserves_first_row_timestamp() { // The gutter clear runs before the timestamp overlay, so it must not // wipe the first-row timestamp. let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::agent_message("hello")); let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); // Seed a ghost in the gutter on the first content row too. let ghost_x = gutter_band(&renderer, width).start + 2; buf.set_string(ghost_x, 0, "X", Style::default()); renderer.render(area, &mut buf); // AgentMessage has no vpad → first content row is y=0. let expected = chrono::Local::now().format("%-I:%M %p").to_string(); let ts_width = expected.len() as u16; let ts_x = width - 2 - ts_width; let rendered = collect_row_symbols(&buf, 0, ts_x, ts_x + ts_width); assert_eq!( rendered, expected, "timestamp must survive the gutter clear on the first row" ); } #[test] fn background_block_gutter_uses_block_background_fill() { // Background blocks own the gutter via the existing full-area fill, so // the no-bg clear must not run for them. Concrete theme so bg_light != // bg_base (Theme::current() quantizes both to Reset in the test env). let theme = Theme::kiginight(); assert_ne!( theme.bg_light, theme.bg_base, "test premise: block bg must differ from base bg" ); let entry = ScrollbackEntry::new(RenderBlock::user_prompt("hello")); let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); // Gutter cell carries the block background, proving the block fill // (not the bg_base clear) owns it. UserPrompt has vpad → content on row 1. let gutter_x = gutter_band(&renderer, width).start + 2; let gutter_cell = buf.cell((gutter_x, 1)).unwrap(); assert_eq!( gutter_cell.bg, theme.bg_light, "background block gutter must use the block bg fill" ); } #[test] fn gutter_keeps_code_block_background_on_no_background_block() { // A code block's per-line bg is painted across the full width (gutter // included); the per-row clear must reuse that bg, not bg_base, or the // code rectangle gets a notch. Concrete theme so the two bgs differ. // // Requires color support: under `NO_COLOR` the global markdown style // carries no code background while this test's local `kiginight()` // theme still has RGB — a mismatch impossible in production. // (Historically this passed under NO_COLOR only because the md_style // Reset→silver fallback bug painted a concrete bg despite the opt-out.) if !crate::theme::color_support::detect().has_color() { return; } let theme = Theme::kiginight(); let mut entry = ScrollbackEntry::new(RenderBlock::agent_message("```\nZZZZ\n```\n")); // The code block's only content row is the first content row, which is // also where the timestamp overlay lands. Drop `created_at` so the // overlay is skipped (it would otherwise paint the right-aligned clock // into the gutter band and clobber the ghost-clear assertion at the // current wall-clock time). `timestamp_reserved()` ignores `created_at`, // so the per-row gutter-ownership path under test still runs. entry.created_at = None; let renderer = EntryRenderer::new(&entry, &theme); let width: u16 = 80; let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let content_left = HorizontalLayout::ACCENT + renderer.appearance.scrollback.layout.block_pad_left; let ghost_x = gutter_band(&renderer, width).start + 2; // Clean render: find the code row by its token, capture its background. let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); let code_row = (0..height) .find(|&y| collect_row_symbols(&buf, y, content_left, width).contains("ZZZZ")) .expect("code block row with ZZZZ must render"); let code_bg = buf.cell((content_left, code_row)).unwrap().bg; assert_ne!( code_bg, theme.bg_base, "test premise: code-block bg must differ from bg_base" ); // (b) Gutter keeps the code-block background (full width) — on the // unfixed `bg_base` fill this cell would be `theme.bg_base != code_bg`. assert_eq!( buf.cell((ghost_x, code_row)).unwrap().bg, code_bg, "code-row gutter must keep the code-block background, not bg_base" ); // (a) A stranded glyph on the code row's gutter is still cleared. let mut buf2 = Buffer::empty(area); buf2.set_string(ghost_x, code_row, "X", Style::default()); renderer.render(area, &mut buf2); assert_eq!( buf2.cell((ghost_x, code_row)).unwrap().symbol(), " ", "code-row gutter ghost must be cleared" ); } // ── estimate_height (lazy off-screen sizing) ── #[test] fn estimate_matches_exact_for_plain_single_line() { let _theme = pin_theme(); // For plain single-line content the cheap estimate must equal the exact // rendered height — this is why total_height stays correct for the many // simple entries that don't wrap or use markdown structure. let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::agent_message("hello world")); let r = EntryRenderer::new(&entry, &theme); assert_eq!(r.estimate_height(80), r.desired_height(80)); } #[test] fn estimate_matches_exact_for_plain_multiline_stub() { let _theme = pin_theme(); let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::stub("a\nb\nc", Color::Blue)); let r = EntryRenderer::new(&entry, &theme); assert_eq!(r.estimate_height(80), r.desired_height(80)); } #[test] fn estimate_collapsed_tool_call_matches_exact() { let _theme = pin_theme(); // A collapsed tool call renders a one-line header; the estimate must NOT // count the (large) hidden body, and must equal the exact height so the // scroll math is correct for sessions full of collapsed calls. let theme = Theme::current(); let mut entry = ScrollbackEntry::new(RenderBlock::tool_call_with_details( "Bash", "ls -la", true, "line1\nline2\nline3\nline4\nline5\nline6\nline7\nline8", )); entry.set_display_mode(DisplayMode::Collapsed); let r = EntryRenderer::new(&entry, &theme); assert_eq!( r.estimate_height(80), r.desired_height(80), "collapsed tool-call estimate must equal exact (compact header)" ); } #[test] fn estimate_collapsed_thinking_matches_exact() { let _theme = pin_theme(); let theme = Theme::current(); let mut entry = ScrollbackEntry::new(RenderBlock::thinking_with_time( "deep\nmulti\nline\nthought", 1500, )); entry.set_display_mode(DisplayMode::Collapsed); let r = EntryRenderer::new(&entry, &theme); assert_eq!( r.estimate_height(80), r.desired_height(80), "collapsed thinking estimate must equal exact (one summary line)" ); } #[test] fn estimate_trailing_newline_does_not_add_a_row() { let _theme = pin_theme(); // `str::lines()` (and the renderers) drop a single trailing newline, so // the estimate must strip it too — otherwise trailing-`\n` source would // estimate one row too many and break estimate == exact. let theme = Theme::current(); let with_nl = ScrollbackEntry::new(RenderBlock::stub("a\nb\nc\n", Color::Blue)); let without_nl = ScrollbackEntry::new(RenderBlock::stub("a\nb\nc", Color::Blue)); let h_with = EntryRenderer::new(&with_nl, &theme).estimate_height(80); let h_without = EntryRenderer::new(&without_nl, &theme).estimate_height(80); assert_eq!(h_with, h_without, "trailing newline must not add a row"); assert_eq!( h_with, EntryRenderer::new(&with_nl, &theme).desired_height(80), "estimate with trailing newline still equals exact" ); } #[test] fn estimate_uses_entry_level_foldability_for_collapsed_shortcut() { let _theme = pin_theme(); // An AgentMessage block is NOT block-foldable, but attaching hooks makes // the ENTRY foldable (matching the fold path). A Collapsed foldable entry // takes the compact ~1-line shortcut; a non-foldable one estimates its body. use crate::scrollback::blocks::tool::hook::{ HookRunEntry, HookRunStatus, ToolCallHookData, }; let theme = Theme::current(); // AgentMessage renders as markdown (single newlines collapse to spaces), so // force a multi-row body with length, not line count. let body = "word ".repeat(60); // No hooks → not foldable → a Collapsed entry estimates its body, not the // 1-line fold shortcut. let mut plain = ScrollbackEntry::new(RenderBlock::agent_message(body.as_str())); plain.set_display_mode(DisplayMode::Collapsed); let plain_est = EntryRenderer::new(&plain, &theme).estimate_height(80); assert!( plain_est > 1, "non-foldable collapsed entry estimates its body, not the shortcut (got {plain_est})" ); // With hooks → entry-level foldable → compact shortcut (1 line, no vpad). let mut hooked = ScrollbackEntry::new(RenderBlock::agent_message(body.as_str())); hooked.set_display_mode(DisplayMode::Collapsed); hooked.hook_data = Some(ToolCallHookData { pre_hooks: vec![HookRunEntry { name: "fmt".into(), status: HookRunStatus::Success { elapsed: std::time::Duration::from_millis(1), }, output: None, }], ..Default::default() }); let hooked_est = EntryRenderer::new(&hooked, &theme).estimate_height(80); assert_eq!( hooked_est, 1, "hook-foldable collapsed entry uses the compact shortcut" ); assert!( plain_est > hooked_est, "entry-level foldability must change the collapsed estimate \ (plain {plain_est} vs hooked {hooked_est})" ); } #[test] fn estimate_differs_from_exact_for_wrapping_text() { let _theme = pin_theme(); // The estimate is a cheap char-ceil that ignores word boundaries, so for // word-heavy content at a narrow width the exact word-wrapped height is // larger. This proves the estimate is genuinely an approximation (so the // viewport-exact measurement path is actually doing work). let theme = Theme::current(); let appearance = AppearanceConfig { show_timestamps: false, ..Default::default() }; let entry = ScrollbackEntry::new(RenderBlock::agent_message( "msg aaaaaaaaaa bbbbbbbbbb cccccccccc dddddddddd eeeeeeeeee ffffffffff", )); let r = EntryRenderer::new(&entry, &theme).with_appearance(appearance); let est = r.estimate_height(20); let exact = r.desired_height(20); assert!( est < exact, "word-wrap height (exact={exact}) should exceed char-ceil estimate (est={est})" ); } #[test] fn estimate_wrapped_line_count_saturates_at_u16_max() { // > u16::MAX source lines must cap, not overflow the running total. let many = "\n".repeat(70_000); assert_eq!(estimate_wrapped_line_count(&many, 80), u16::MAX); } #[test] fn estimate_height_saturates_instead_of_overflowing() { let _theme = pin_theme(); // A pathologically tall block (multi-MB source) hits the line-count cap; // adding vpad on top must SATURATE, not overflow (debug panic / release // wrap → corrupt virtual_y). Stub has vpad, so this exercises the // saturating_add in `assemble_height`. let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::stub("\n".repeat(70_000), Color::Blue)); let r = EntryRenderer::new(&entry, &theme); assert_eq!(r.estimate_height(80), u16::MAX); } #[test] fn estimate_uses_display_width_not_byte_length() { let _theme = pin_theme(); // Wide (CJK) glyphs are 2 display columns but 3 UTF-8 bytes each. The // estimate must wrap on DISPLAY width, not byte length, so a 10-glyph // wide string (display 20, bytes 30) sizes like 20 ascii cols (bytes 20) // and NOT like 30 ascii cols (bytes 30). let theme = Theme::current(); // Narrow width so 20 vs 30 display columns wrap to different counts. let height = |text: &str| { let entry = ScrollbackEntry::new(RenderBlock::stub(text, Color::Blue)); EntryRenderer::new(&entry, &theme).estimate_height(14) }; let wide = height("一二三四五六七八九十"); // display 20, bytes 30 let ascii_same_display = height("xxxxxxxxxxxxxxxxxxxx"); // display 20, bytes 20 let ascii_same_bytes = height("xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"); // display 30, bytes 30 assert_eq!( wide, ascii_same_display, "wraps by display width: wide(20 cols) == ascii(20 cols)" ); assert_ne!( wide, ascii_same_bytes, "must NOT wrap by byte length: wide(30 bytes) != ascii(30 cols)" ); } // ── diagram affordance-row reservation ── /// A diagram under `auto`/`on` reserves exactly one extra row — the /// affordance row — and the off-screen estimate accounts for it so a bulk /// load never under-reserves. Rendering is lazy, so the row is always present /// regardless of any on-click render. #[test] fn affordance_row_adds_one_row_per_diagram() { let _theme = pin_theme(); let theme = Theme::current(); let md = "intro line\n\n```mermaid\nA-->B\n```\n\nafterword line\n"; // Baselines with the setting OFF (no treatment row). Both the exact and // the source-based estimate are captured in this window because // `estimate_extra_rows`/`output()` read the *current* global setting. crate::appearance::cache::set_render_mermaid(crate::appearance::RenderMermaid::Off); let off_entry = ScrollbackEntry::new(RenderBlock::agent_message(md)); let h_off = EntryRenderer::new(&off_entry, &theme).desired_height(80); let est_off = EntryRenderer::new(&off_entry, &theme).estimate_height(80); // Engine on → exactly one extra (affordance) row over the plain block. crate::appearance::cache::set_render_mermaid(crate::appearance::RenderMermaid::On); let on_entry = ScrollbackEntry::new(RenderBlock::agent_message(md)); let r = EntryRenderer::new(&on_entry, &theme); let h_on = r.desired_height(80); assert_eq!(h_on, h_off + 1, "the affordance row adds exactly one row"); // The off-screen estimate accounts for the affordance row (one per // diagram) and so never under-reserves vs the realized height — the // invariant a bulk load (`kigi -r`) relies on to avoid clipping. let est_on = r.estimate_height(80); assert_eq!( est_on, est_off + 1, "estimate adds one row per diagram when the engine is on", ); assert!( est_on >= h_on, "estimate must not under-reserve: est {est_on} >= desired {h_on}", ); } #[test] fn thinking_entry_height_zero_when_show_thinking_blocks_off() { let theme = Theme::current(); let entry = ScrollbackEntry::new(RenderBlock::thinking("reason step by step")); crate::appearance::cache::set_show_thinking_blocks(true); let on = EntryRenderer::new(&entry, &theme); let h_on = on.desired_height(80); let est_on = on.estimate_height(80); let trunc_on = on.compute_truncated_height(80); assert!(h_on > 0, "thinking must occupy rows when shown"); assert!(est_on > 0); assert!(trunc_on > 0); crate::appearance::cache::set_show_thinking_blocks(false); let off = EntryRenderer::new(&entry, &theme); assert_eq!(off.desired_height(80), 0); assert_eq!(off.estimate_height(80), 0); assert_eq!(off.compute_truncated_height(80), 0); // Restore process default (off). crate::appearance::cache::set_show_thinking_blocks(false); } // ── flat_background × per-line backgrounds (minimal mode) ── /// Whether any cell in the buffer carries `bg` as its background. fn buf_has_bg(buf: &Buffer, bg: Color) -> bool { let area = *buf.area(); (area.y..area.bottom()) .any(|y| (area.x..area.right()).any(|x| buf.cell((x, y)).unwrap().bg == bg)) } fn render_to_buf(renderer: &EntryRenderer<'_>, width: u16) -> Buffer { let height = renderer.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); renderer.render(area, &mut buf); buf } /// Fixed injected line-bg color for the flat-background tests. A raw RGB /// (not a theme color): the stub injects it directly into its output, so /// the tests are independent of `Theme::current()` — whose process-global /// kind / color-level state other tests mutate in a parallel run. const LINE_BG: Color = Color::Rgb(12, 34, 56); #[test] fn flat_background_suppresses_panel_line_bg() { // Read/Search/etc. tool previews paint a decorative per-line panel // band (marked `background_is_panel`). In minimal mode // (flat_background) that fixed-color band clashes with the terminal's // own background, so it must be dropped — the block-level suppression // alone doesn't cover it because those blocks declare // `BlockBackground::None` and shade per line. (That Read/Search mark // their previews as panel is pinned by block-side tests.) use crate::scrollback::block::StubBlock; let theme = Theme::kiginight(); let entry = ScrollbackEntry::new(RenderBlock::Stub( StubBlock::new("alpha\nbravo", Color::Blue).with_line_bg(LINE_BG, true), )); // Control: the default (alt-screen) render paints the panel band. let renderer = EntryRenderer::new(&entry, &theme); assert!( buf_has_bg(&render_to_buf(&renderer, 40), LINE_BG), "test premise: non-flat render must paint the panel band" ); // Flat (minimal mode): the panel must not paint anywhere. let flat = EntryRenderer::new(&entry, &theme).with_flat_background(true); assert!( !buf_has_bg(&render_to_buf(&flat, 40), LINE_BG), "flat render must suppress panel line backgrounds" ); } #[test] fn flat_background_keeps_semantic_line_bg() { // Semantic per-line backgrounds — diff insert/delete rows and // markdown code-block fill (NOT marked `background_is_panel`) — must // survive flat mode: they carry meaning, unlike the decorative // tool-preview panels. use crate::scrollback::block::StubBlock; let theme = Theme::kiginight(); let entry = ScrollbackEntry::new(RenderBlock::Stub( StubBlock::new("alpha\nbravo", Color::Blue).with_line_bg(LINE_BG, false), )); let flat = EntryRenderer::new(&entry, &theme).with_flat_background(true); assert!( buf_has_bg(&render_to_buf(&flat, 40), LINE_BG), "flat render must keep semantic (non-panel) line backgrounds" ); } }