The PRD's first acceptance gate now holds: grep -RinE '\bx\.ai\b|grok' crates/ --include='*.rs' → 0 matches (exempt: NOTICE and third-party license archives, README provenance, and the required 'Based on Grok Build Open Source' attribution, now sourced from version_attribution.txt). Wire-visible renames (both sides in this repo, changed in lockstep): - Auth method id 'grok.com' → 'kimi-code' (AuthMethodKind::KimiCode). - Every x.ai/* and _x.ai/* ACP ext method and meta key → kigi/* / _kigi/* (~200 names; grokShell → kigiShell). Session-file replay keeps a read-side alias for the legacy '_x.ai/session/update' method so existing updates.jsonl histories load; writes emit only the new name (both directions test-pinned). - Agent types grok-build* → kigi* with a documented legacy-prefix alias at resolution time so persisted sessions keep resolving. - ToolNamespace/BuiltinAgentName GrokBuild* → Kigi* (wire snake_case kigi/kigi_concise/kigi_hashline; schema regenerated); grok_build implementation dirs renamed to kigi*. - x-grok-* headers → x-kigi-*, __GROK_* sentinels → __KIGI_*, themes grokday/groknight → kigiday/kiginight (old persisted values fall back to the default theme), web_fetch allowlist xAI hosts → kimi.com + moonshot platforms, changelog CDN → this repo, grok-build changelog archives deleted. - BYOK default endpoint removed: [endpoints] api_base_url is now truly optional with NO default — consumers fail fast with the flag name when unset (no silent x.ai egress). Mock harnesses inject it explicitly. - System-prompt identity fixed: 'released by xAI' → 'an unofficial community CLI for Kimi' (template + regenerated encrypted form). Also repaired pre-existing grok-era test debt found by the sweep: the stale trace_classify default-model pin, the grok-pager UA label test, pty-harness stale-binary reuse and non-hermetic moonshot routing (a PTY test could previously reach the real api.moonshot.cn), and the outdated oauth fixture scope key. Gates: §9 grep 0; fmt clean; workspace check/clippy 0/0 (-D warnings); FULL cargo test --workspace: 234 suites, 21,961 passed, 0 failed; deny advisories ok.
1915 lines
82 KiB
Rust
1915 lines
82 KiB
Rust
//! 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<AccentStyle> {
|
||
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>, 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<ratatui::style::Color> = 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<u16> {
|
||
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"
|
||
);
|
||
}
|
||
}
|