Files
Kigi-CLI/crates/codegen/kigi-tui/src/scrollback/render.rs
T
ZacharyZhang-NY 5e4e24db99 M2 audit: excise managed connectors and xAI media-gen tools
Managed connectors (grok.com MCP admin) removed root-and-branch:
- The managed-MCP fetch/injection pipeline is gone, including the whole
  kigi-shell-session-support crate (managed-config fetch client, gateway
  tool catalog + dispatch, header injection, refresh task), reactive
  managed re-auth, mcp_doctor's grok.com-source discovery, and the
  [managed_mcps] config surface.
- TUI: the 'Managed by grok.com' section, connectors URL/deep-link,
  Action::OpenManagedConnectors, and session_team_id are gone. Local MCP
  management (list/toggle/add/remove/auth/tools) is fully intact.
- Kept as LOCAL policy: managed-settings.json MCP allow/deny enforcement,
  the multi-source local MCP merge, folder-trust gating. PluginOrigin
  Project/User labels kept (they tag locally discovered plugin dirs).

imagine/media-gen tools (xAI image/video generation) removed:
- image_gen, image_edit, video_gen, image_to_video, reference_to_video
  implementations, registrations, ToolKind/ToolInput/Output variants
  (serde-safe), config plumbing end to end, ZDR video machinery,
  /imagine + /imagine-video commands and guidance text, the bundled
  imagine skill (added to legacy cleanup so user installs delete it),
  and the media-gen render path.
- Kept: image INPUT (paste/attach, [Image #N] meta, pdf/image fetch,
  clipboard wrap), generic media-ref rendering, and the generic tool
  401-retry machinery (tests renamed, assertions unweakened).
- deploy_app stays: it is a permanently-disabled local stub deploying
  nowhere.

121 files changed, 8 deleted. Gates: workspace check/clippy 0/0, fmt,
deny ok; suites green (tools 2554, shell 4862, tui 6608, workspace
1042). Remaining grok.com strings live only in the auth-method ids and
changelog archives (§9/M3 sweep).
2026-07-17 23:45:05 -04:00

4177 lines
162 KiB
Rust

//! Scroll-aware rendering for scrollback entries.
use std::sync::Arc;
use derive_more::{Deref, DerefMut};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::block::{BlockContent, RenderBlock};
use super::entry::ScrollbackEntry;
use super::layout::HorizontalLayout;
use super::state::EntryLayoutInfo;
use super::state::groups::{GroupKind, GroupSpan, span_containing};
use super::state::verb_group::{
GroupHeaderLabel, truncation_header_label, verb_group_header_label,
};
use super::text_selection::{
ResolvedSelectableLine, ResolvedSelectionBoundaries, ResolvedSelectionModel,
VisibleBlockGeometry,
};
use super::types::{
DisplayMode, derive_selection_text, line_plain_text_into, selectable_cols,
selectable_cols_usize,
};
use super::wrappers::{EntryRenderer, group_header_chrome_prefix_width};
use crate::appearance::AppearanceConfig;
use crate::render::Renderable;
use crate::render::osc8::{LinkOverlay, OverlayLink};
use crate::theme::Theme;
/// `range_id` of a labeled group header's synthetic selectable row — verb-run
/// headers and truncation headers carrying an aggregated label. Reserved at
/// the top of the id space: block `selection_range` ids count up from 0, and
/// in the EXPANDED verb slot member 0's own line 0 (range 0, block line 0) is
/// mapped alongside the header — a shared id would merge both rows into one
/// selectable range, so a drag on either row selected and copied both.
pub(crate) const GROUP_HEADER_RANGE_ID: u16 = u16::MAX;
/// Label for the inline-media native-open text button (terminals without
/// inline graphics). Graphics terminals use a shorter overlay `[Open]` instead.
pub fn media_open_button_label(is_video: bool) -> &'static str {
if is_video {
"[Open Video]"
} else {
"[Open Image]"
}
}
/// Centered left column for the `[Open]` text button. Shared by the renderer
/// and the hit-area computation so the label and click target stay aligned.
pub fn media_open_button_col(content_width: u16, is_video: bool) -> u16 {
let label_w = media_open_button_label(is_video).len() as u16;
content_width.saturating_sub(label_w) / 2
}
/// Width reserved for the timestamp on message blocks.
///
/// Matches the constant in `EntryRenderer::timestamp_reserved()`.
fn timestamp_reserved_for_block(block: &RenderBlock, appearance: &AppearanceConfig) -> u16 {
if appearance.show_timestamps
&& matches!(
block,
RenderBlock::UserPrompt(_) | RenderBlock::AgentMessage(_) | RenderBlock::Btw(_)
)
{
10
} else {
0
}
}
/// A reusable scratch buffer for rendering clipped entries.
///
/// This wraps ratatui's `Buffer` with `Deref`/`DerefMut` so all standard
/// Buffer methods work directly. The wrapper exists to:
/// - Make scratch buffer usage greppable in the codebase
/// - Provide a place for future optimization methods (bulk copy, fill, etc.)
///
/// # Usage
///
/// Create once and reuse across frames:
/// ```ignore
/// let mut scratch = ScratchBuffer::new();
/// // In render loop:
/// scratch.prepare(width, height);
/// // ... use scratch for rendering ...
/// ```
///
/// # Future Enhancements
///
/// ratatui's Buffer has limitations we could address:
/// - `resize()` always reallocates when growing (no capacity tracking)
/// - No efficient `fill()` - could use `vec.fill()` instead of cell-by-cell
/// - No bulk copy operations
#[derive(Default, Deref, DerefMut)]
pub struct ScratchBuffer(Buffer);
impl ScratchBuffer {
/// Create a new empty scratch buffer.
pub fn new() -> Self {
Self(Buffer::default())
}
/// Resize and reset buffer for reuse.
///
/// We must reset because `set_style()` only changes style, not content.
/// If previous content was longer than new content, old chars would remain.
/// TODO: When we fork Buffer, use `vec.fill(Cell::default())` for efficiency.
pub fn prepare(&mut self, width: u16, height: u16) {
self.resize(Rect::new(0, 0, width, height));
self.reset();
}
/// Prepare and return mutable reference (convenience for chaining).
pub fn prepared(&mut self, width: u16, height: u16) -> &mut Self {
self.prepare(width, height);
self
}
}
/// A visible inline media entry with its screen position and crop info.
#[derive(Debug, Clone)]
pub struct InlineMediaPlacement {
/// Media metadata (path, dimensions, type).
pub info: crate::prompt_images::InlineMediaInfo,
/// Screen rect where the visible portion of the image is rendered.
pub screen_rect: ratatui::layout::Rect,
/// Total image rows when fully visible (for crop calculation).
pub full_rows: u16,
/// Number of rows cropped from the top (0 = no crop).
pub top_crop_rows: u16,
/// Screen rect of the filepath line (second line of the media block header),
/// if visible. Used for click-to-copy hit testing.
pub filepath_screen_rect: Option<ratatui::layout::Rect>,
/// Screen rect of the text `[Open]` button line, if visible. Present only
/// for text-button placements (media on terminals without inline-graphics
/// support; `full_rows` is 0). Used for click-to-open-natively hit testing.
pub open_button_screen_rect: Option<ratatui::layout::Rect>,
/// Whether this placement reserves a trailing `[Open]/[Copy]` (or play)
/// button row beneath the image. True for an overlay/image tool-media
/// placement; false for the text-`[Open]` placement (terminals without
/// inline graphics), whose button is the `[Open]` text line itself.
pub has_button_row: bool,
}
/// A visible Mermaid diagram affordance row with its screen position and the
/// diagram source its buttons act on. The draw loop paints
/// `◇ mermaid [Open Image] [Copy Image Path] [Copy Source]` onto `screen_rect` and registers
/// the click hit-rects; the reserved (blank) row already scrolls with the
/// surrounding content. Rendering is lazy (driven from the source on click), so
/// no rendered path/state is carried here.
#[derive(Debug, Clone)]
pub struct DiagramAffordancePlacement {
/// Screen rect of the affordance row (one row tall, content-area width).
pub screen_rect: ratatui::layout::Rect,
/// Diagram source (the fence body); the data every button acts on.
pub source: String,
}
/// Result of rendering entries.
#[derive(Debug, Clone, Default)]
pub struct ScrollRenderResult {
/// Virtual-y end of the passed slice: `content_y0` + heights + gaps of the
/// entries given to the renderer. Equals the full content height only when
/// the caller passes the full list from content top; the production
/// windowed caller ignores it and uses `prepare_layout()`'s total.
/// `usize` so tall sessions (> `u16::MAX` rows) are not truncated.
pub total_height: usize,
/// Area occupied by the selected entry (if visible).
/// This is used for drawing selection borders.
/// None if selected entry is not visible or partially clipped.
pub selected_area: Option<SelectedEntryArea>,
/// Per-frame resolved selection metadata for visible content.
pub selection_model: ResolvedSelectionModel,
/// Accumulated link overlay for OSC 8 post-flush emission.
pub link_overlay: LinkOverlay,
/// Inline media to render via post-flush escape sequences.
pub inline_media: Vec<InlineMediaPlacement>,
/// Diagram affordance rows to paint + register click hit-rects for.
pub diagram_affordances: Vec<DiagramAffordancePlacement>,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct ScrollRenderResultWithBoundaries {
pub(crate) result: ScrollRenderResult,
pub(crate) selection_boundaries: ResolvedSelectionBoundaries,
}
/// Information about the selected entry's visible area.
#[derive(Debug, Clone)]
pub struct SelectedEntryArea {
/// The area where the entry was rendered.
pub area: Rect,
/// Whether the top of the entry is clipped (don't draw top border).
pub top_clipped: bool,
/// Whether the bottom of the entry is clipped (don't draw bottom border).
pub bottom_clipped: bool,
}
/// Render entries with scroll support.
///
/// # Parameters
/// - `entry_layouts_cache`: Pre-computed layout info (height + gap_after) for each entry.
/// Must be same length as `entries`. Comes from the LayoutCache populated by `prepare_layout()`.
/// - `tick`: Animation tick counter for animated elements (e.g., running block accents).
/// - `content_y0`: Virtual-Y of `entries[0]` in scroll-offset space (0 when the
/// slice starts at content top). Callers that pass a viewport-only window set
/// this from the layout cache so off-screen history is not re-walked here.
/// - `entry_index_base`: Added to each slice index for selection-model indices and
/// `selected_idx` / `dim_from_entry` matching (relative to the caller's full
/// visible range, not the paint window).
/// - `group_spans`: The fold model from the layout cache plus the absolute
/// entry index of `entries[0]`, used to bound verb-group header labels to
/// exactly the folded run and to build the aggregated labels on truncation
/// headers. `None` (harnesses without a layout pass) falls back to the
/// verb label walk's own run classification and to the plain "N more" /
/// "N tool calls & thoughts" truncation text.
/// - `cwd`: Session/worktree cwd used for path-aware measurement and paint.
///
/// # Panics
/// Debug-asserts if `entry_layouts_cache.len() != entries.len()`.
#[allow(clippy::too_many_arguments)]
pub fn render_scrolled_entries_with_scratch(
buf: &mut Buffer,
viewport: Rect,
entries: &[&ScrollbackEntry],
scroll_offset: usize,
selected_idx: Option<usize>,
theme: &Theme,
appearance: &AppearanceConfig,
entry_layouts_cache: &[EntryLayoutInfo],
tick: u64,
mouse_pos: Option<(u16, u16)>,
dim_from_entry: Option<usize>,
search_highlight: Option<&regex::Regex>,
content_y0: usize,
entry_index_base: usize,
// Absolute paths of media generated in this transcript, used to resolve the
// short relative paths the model prints (`images/1.jpg`) to clickable links.
media_paths: &[std::path::PathBuf],
group_spans: Option<(&[GroupSpan], usize)>,
cwd: Option<&std::path::Path>,
) -> ScrollRenderResult {
render_scrolled_entries_with_selection_boundaries(
buf,
viewport,
entries,
scroll_offset,
selected_idx,
theme,
appearance,
entry_layouts_cache,
tick,
mouse_pos,
dim_from_entry,
search_highlight,
content_y0,
entry_index_base,
media_paths,
group_spans,
cwd,
)
.result
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn render_scrolled_entries_with_selection_boundaries(
buf: &mut Buffer,
viewport: Rect,
entries: &[&ScrollbackEntry],
scroll_offset: usize,
selected_idx: Option<usize>,
theme: &Theme,
appearance: &AppearanceConfig,
entry_layouts_cache: &[EntryLayoutInfo],
tick: u64,
mouse_pos: Option<(u16, u16)>,
dim_from_entry: Option<usize>,
search_highlight: Option<&regex::Regex>,
content_y0: usize,
entry_index_base: usize,
media_paths: &[std::path::PathBuf],
group_spans: Option<(&[GroupSpan], usize)>,
cwd: Option<&std::path::Path>,
) -> ScrollRenderResultWithBoundaries {
if entries.is_empty() || viewport.width == 0 || viewport.height == 0 {
return ScrollRenderResultWithBoundaries::default();
}
let mut result = ScrollRenderResult::default();
let mut selection_boundaries = ResolvedSelectionBoundaries::default();
debug_assert_eq!(
entry_layouts_cache.len(),
entries.len(),
"entry_layouts_cache must match entries length - was prepare_layout() called?"
);
// Create horizontal layout for this viewport using config
let layout = HorizontalLayout::new(viewport, &appearance.scrollback.layout);
// Total height = y of first passed entry + span of this slice (incl. gaps).
// Use usize so tall sessions are never truncated. When the caller
// passes a viewport window, this is only the window's end — production uses
// prepare_layout()'s total; full-slice callers (tests) get the true total.
result.total_height = content_y0
+ entry_layouts_cache
.iter()
.map(|l| l.height as usize + l.gap_after as usize)
.sum::<usize>();
let viewport_start = scroll_offset;
let viewport_end = scroll_offset + viewport.height as usize;
result.selection_model.content_area = layout.content;
// Reused across all visible rows so the search-highlight pass allocates at
// most once per frame (not once per row). Empty until search is active.
let mut highlight_text = String::new();
// Walk only the passed slice (viewport window in production). No full-list
// EntryLayout vec — y advances from content_y0 using cached heights/gaps.
let mut y = content_y0;
for (i, entry) in entries.iter().enumerate() {
let height = entry_layouts_cache[i].height;
let entry_start = y;
let entry_end = entry_start + height as usize;
// Skip if completely above viewport
if entry_end <= viewport_start {
y = entry_end + entry_layouts_cache[i].gap_after as usize;
continue;
}
// Stop if completely below viewport
if entry_start >= viewport_end {
break;
}
let logical_idx = i + entry_index_base;
// Calculate visibility
let top_clipped = entry_start < viewport_start;
let bottom_clipped = entry_end > viewport_end;
// Calculate render position (narrowed to u16 for screen coordinates —
// these deltas are always within viewport height which fits in u16).
let render_y: u16 = if top_clipped {
viewport.y
} else {
viewport.y + (entry_start - viewport_start) as u16
};
// Calculate visible height
let skip_rows: u16 = if top_clipped {
(viewport_start - entry_start) as u16
} else {
0
};
let visible_height: u16 = if bottom_clipped {
(viewport_end - entry_start.max(viewport_start)) as u16
} else {
height - skip_rows
};
// Calculate actual render height (clamped to viewport)
let render_height = visible_height.min(viewport.height);
if render_height == 0 {
y = entry_end + entry_layouts_cache[i].gap_after as usize;
continue;
}
// Create the area for this entry's content
let entry_row_layout = layout.for_row(render_y, render_height);
let entry_content_area = entry_row_layout.entry_content_area();
// Render the entry — skip_rows handles partial visibility directly
let is_selected = selected_idx == Some(logical_idx);
let entry_layout_info = &entry_layouts_cache[i];
// Group headers rebuild their aggregated label each frame so counts/
// tense/live target track the streaming run in place. Both fold
// families feed the one label channel; a header row belongs to
// exactly one fold, so the branches are exclusive by construction.
//
// Verb-group headers: the fold's span bounds the walk to exactly the
// claimed run; without spans the walk stops at its own run-breaker
// classification.
let header_label = if entry_layout_info.verb_group_header {
let show_thinking = crate::appearance::cache::load_show_thinking_blocks();
let end = group_spans
.and_then(|(spans, base)| {
let span = span_containing(spans, base + i)?;
Some(span.range.end.saturating_sub(base))
})
.unwrap_or(entries.len());
Some(GroupHeaderLabel::VerbRun(verb_group_header_label(
entries,
i,
end,
show_thinking,
theme,
)))
} else if entry_layout_info.is_group_header()
&& crate::appearance::cache::load_group_tool_verbs()
{
// Truncation headers get the same aggregated vocabulary over the
// rows they hide (prefix only while collapsed; the whole run when
// expanded), gated on the "Group tool calls" setting that owns
// this vocabulary. Falls back to the renderer's plain "N more"
// count when the setting is off, spans are absent, or the walk
// declines (pure thoughts, or hidden rows it cannot name).
let show_thinking = crate::appearance::cache::load_show_thinking_blocks();
group_spans
.and_then(|(spans, base)| {
let span = span_containing(spans, base + i)?;
let GroupKind::Truncation { hidden, .. } = span.kind else {
return None;
};
let start = span.range.start.saturating_sub(base);
let end = span.range.end.saturating_sub(base);
truncation_header_label(
entries,
start..end,
(!span.expanded).then_some(hidden),
show_thinking,
theme,
)
})
.map(GroupHeaderLabel::Truncation)
} else {
None
};
let renderer = EntryRenderer::new(entry, theme)
.with_appearance(appearance.clone())
.with_tick(tick)
.with_skip_rows(skip_rows)
.with_groupable(entry.block.is_groupable())
.with_selected(is_selected)
.with_mouse_pos(mouse_pos)
.with_group_header_count(entry_layout_info.group_header_count)
.with_group_collapse_header(entry_layout_info.group_collapse_header)
.with_group_header_label(header_label.as_ref())
.with_cwd(cwd);
renderer.render(entry_content_area, buf);
if dim_from_entry.is_some_and(|d| logical_idx >= d) {
let dim_fg = theme.gray_dim;
for cy in entry_content_area.y..entry_content_area.y + entry_content_area.height {
for cx in entry_content_area.x..entry_content_area.x + entry_content_area.width {
if let Some(cell) = buf.cell_mut((cx, cy)) {
cell.fg = dim_fg;
}
}
}
}
// Use cached output for selection model building.
// EntryRenderer::render() above already populated the cache for non-selected
// entries. For selected entries, ensure_cached() will compute and cache the
// output once. This avoids a redundant block.output() call (which includes
// expensive syntax highlighting for edit blocks) that was previously done
// via effective_output() on every frame.
//
// Must use the same effective width as the renderer (reduced by timestamp
// reservation for message blocks) to avoid cache thrashing.
let ts_reserved = timestamp_reserved_for_block(&entry.block, appearance);
let content_width = entry_row_layout.content_width().saturating_sub(ts_reserved);
entry.ensure_cached(content_width, appearance, is_selected, cwd);
let cached_rendered = entry.cached_rendered_output_ref();
let cached_output = &cached_rendered.output;
let cached_boundaries = &cached_rendered.boundaries;
result
.selection_model
.visible_blocks
.push(VisibleBlockGeometry {
entry_idx: logical_idx,
area: entry_row_layout.entry_area(),
content_area: entry_row_layout.content,
selection_area: entry_row_layout.selection_area(),
content_width,
top_clipped,
bottom_clipped,
drag_startable: entry.block.is_drag_block_selectable(),
});
let ctx = entry.context(content_width, appearance, cwd);
let has_vpad = entry.block.has_vpad(&ctx);
let vpad_top = if has_vpad { 1u16 } else { 0 };
let content_skip = skip_rows.saturating_sub(vpad_top) as usize;
let first_visible_content_y = render_y + if skip_rows < vpad_top { 1 } else { 0 };
let max_y = render_y + render_height;
// Group-header entries draw synthetic "N more" text instead of
// `cached_output.lines` (the truncation fold forces height 1), so
// every pass mapping content lines or geometry to screen rows skips
// them. The EXPANDED verb-group slot is the exception: its header line
// sits above member 0's own content, which stays mapped (selectable
// like every other member row) one row below.
let is_group_header = entry_layout_info.is_group_header();
let verb_expanded_slot =
entry_layout_info.verb_group_header && entry_layout_info.group_collapse_header;
let mapped_lines = if is_group_header && !verb_expanded_slot {
&[][..]
} else {
&cached_output.lines[..]
};
// Expanded verb-group slot: the header consumes the slot's first
// screen row, so member 0's content maps one row below (mirroring
// EntryRenderer's collapse-header render path: when the header is
// scrolled off, the first skipped row is the header, not content).
// Shared by the selection lines, the hyperlink map, and the URL
// scanner below — they all read these two offsets.
let (first_visible_content_y, content_skip) = if verb_expanded_slot {
if skip_rows == 0 {
(first_visible_content_y + 1, content_skip)
} else {
(first_visible_content_y, content_skip.saturating_sub(1))
}
} else {
(first_visible_content_y, content_skip)
};
// Labeled group header (either fold family): one synthetic selectable
// row so drag/copy on the header yields the aggregated label text.
// Plain-count headers carry no label and stay non-selectable.
if let Some(header) = &header_label
&& skip_rows == 0
&& render_y < max_y
{
let label = header.label();
// Every labeled header draws the diamond chrome before the label;
// shift the hitbox onto the label glyphs so highlight matches
// the copied text (the chrome is affordance, not content).
let chrome_offset = group_header_chrome_prefix_width();
result.selection_model.push_line(ResolvedSelectableLine {
entry_idx: logical_idx,
range_id: GROUP_HEADER_RANGE_ID,
block_line_idx: 0,
screen_y: render_y,
screen_x: entry_row_layout.content.x.saturating_add(chrome_offset),
selectable_cols: 0..(label.line.width() as u16),
text: label.text.clone(),
joiner_to_previous: None,
});
}
for (screen_y, (block_line_idx, line)) in
(first_visible_content_y..).zip(mapped_lines.iter().enumerate().skip(content_skip))
{
if screen_y >= max_y {
break;
}
// Search highlight: re-run the query regex over this rendered row
// and invert matching cells (decoupled from the source-text index,
// mirroring `list_pane`). Each `BlockLine` is one already-wrapped
// screen row, so the single-row paint path applies.
//
// The haystack is the rendered glyphs, not the indexed source text,
// so the highlighted set can diverge from the index match set: a
// match split across a soft-wrap boundary highlights on neither row,
// and markdown markers present in source but absent on screen won't
// highlight. This is the intended decoupling — navigation/counting
// use the index; on-screen highlighting follows what's drawn.
if let Some(re) = search_highlight {
highlight_text.clear();
line_plain_text_into(&line.content, &mut highlight_text);
crate::render::highlight::paint_match_highlights(
buf,
entry_row_layout.content,
screen_y,
max_y,
0,
0,
&highlight_text,
re,
true,
);
}
if let (Some(range_id), Some(cols)) = (
line.selection_range,
selectable_cols(&line.content, &line.selectable),
) {
let resolved_line = ResolvedSelectableLine {
entry_idx: logical_idx,
range_id,
block_line_idx,
screen_y,
screen_x: entry_row_layout.content.x,
selectable_cols: cols,
text: derive_selection_text(line),
joiner_to_previous: line.joiner.clone(),
};
if let Some(boundary) = cached_boundaries.get(block_line_idx) {
selection_boundaries.push(&resolved_line, Arc::clone(boundary));
}
result.selection_model.push_line(resolved_line);
}
}
// Collect hyperlinks for the link overlay. Group headers render
// synthetic label text with no links; the expanded verb slot's member
// row keeps its links (row offsets already shifted past the header).
if !is_group_header || verb_expanded_slot {
let content_line_offset = match &entry.block {
RenderBlock::Btw(_) if ctx.mode != DisplayMode::Collapsed => 2,
RenderBlock::Thinking(_)
if ctx.mode != DisplayMode::Collapsed
&& appearance.scrollback.blocks.thinking.header =>
{
2
}
_ => 0,
};
entry.block.with_hyperlinks(|hyperlinks| {
if !hyperlinks.is_empty() {
map_hyperlinks_to_overlay(
hyperlinks,
cached_output,
content_skip,
first_visible_content_y,
max_y,
entry_row_layout.content.x,
content_line_offset,
media_paths,
&mut result.link_overlay,
);
}
});
// Tool-header link_url overlays before plain-text scan (basename/relative
// paint still needs absolute file://). Hit box = selectable path span
// (respects bullet prepend + Selectable shift).
{
for (idx, bl) in cached_output.lines.iter().enumerate().skip(content_skip) {
let visible_offset = (idx - content_skip) as u16;
let screen_row = first_visible_content_y + visible_offset;
if screen_row >= max_y {
break;
}
let Some(url) = bl.link_url.as_ref() else {
continue;
};
let Some(cols) = selectable_cols_usize(&bl.content, &bl.selectable) else {
continue;
};
let visible_width =
usize::from(content_width.min(entry_row_layout.content.width));
let start = cols.start.min(visible_width);
let end = cols.end.min(visible_width);
if start >= end {
continue;
}
let (Ok(start), Ok(end)) = (u16::try_from(start), u16::try_from(end)) else {
continue;
};
let (Some(col_start), Some(col_end)) = (
entry_row_layout.content.x.checked_add(start),
entry_row_layout.content.x.checked_add(end),
) else {
continue;
};
if result.link_overlay.overlaps(screen_row, col_start, col_end) {
continue;
}
result.link_overlay.push(OverlayLink {
screen_row,
col_start,
col_end,
url: Arc::clone(url),
id: None,
});
}
}
// Scan post-wrap lines for plain-text URLs and file paths.
// For markdown blocks, markdown hyperlinks are already in the
// overlay (mapped above); explicit tool-link rows are authoritative.
{
let visible_lines = cached_output
.lines
.iter()
.enumerate()
.skip(content_skip)
.filter(|(_, bl)| bl.link_url.is_none())
.map(|(idx, bl)| {
let visible_offset = (idx - content_skip) as u16;
let screen_row = first_visible_content_y + visible_offset;
(screen_row, &bl.content, bl.joiner.as_deref())
})
.take_while(|(screen_row, _, _)| *screen_row < max_y);
crate::render::osc8::scan_lines_for_url_overlays(
visible_lines,
entry_row_layout.content.x,
media_paths,
&mut result.link_overlay,
);
}
}
// Collect inline media placements for visible media. Each media block
// (tool media only) yields one trailing placement anchored at its own
// `row_offset`. Partial visibility crops top/bottom so the image slides
// into/out of view during scrolling.
// Member 0's content starts one virtual row below the slot's header
// line; every virtual anchor below offsets from here.
let content_y_start = entry_start + usize::from(verb_expanded_slot);
let media_placements = if is_group_header && !verb_expanded_slot {
Vec::new()
} else {
entry.block.inline_media_placements(&ctx)
};
for placement in media_placements {
let image_offset = placement.row_offset as usize;
let full_image_h = placement.rows as usize;
let image_virtual_start = content_y_start + image_offset;
let image_virtual_end = image_virtual_start + full_image_h;
let viewport_bottom = viewport_start + viewport.height as usize;
// Keep the image clear of the right-aligned timestamp overlay
// (message blocks reserve trailing columns for it); tool blocks
// reserve 0, so this is a no-op there.
let media_width = entry_content_area.width.saturating_sub(ts_reserved);
// Check if any part of the image area is visible (height 1 = hint-only banner).
if image_virtual_start < viewport_bottom
&& image_virtual_end > viewport_start
&& full_image_h >= 1
&& media_width >= 4
{
// Compute visible portion, cropping top and bottom.
// Results are narrowed to u16 — they are viewport-relative
// offsets that always fit in screen coordinates.
let top_crop = viewport_start.saturating_sub(image_virtual_start) as u16;
let visible_start = image_virtual_start.max(viewport_start);
let visible_end = image_virtual_end.min(viewport_bottom);
let visible_h = visible_end.saturating_sub(visible_start) as u16;
let screen_y = viewport.y + (visible_start - viewport_start) as u16;
if visible_h >= 1 {
// Tool media exposes its second output line as the
// click-to-copy filepath and reserves a button row.
let filepath_virtual_y = content_y_start + 1;
let filepath_screen_rect = if filepath_virtual_y >= viewport_start
&& filepath_virtual_y < viewport_bottom
{
Some(ratatui::layout::Rect {
x: entry_content_area.x,
y: viewport.y + (filepath_virtual_y - viewport_start) as u16,
width: entry_content_area.width,
height: 1,
})
} else {
None
};
result.inline_media.push(InlineMediaPlacement {
info: placement.info,
screen_rect: ratatui::layout::Rect {
x: entry_content_area.x,
y: screen_y,
width: media_width,
height: visible_h,
},
full_rows: full_image_h as u16,
top_crop_rows: top_crop,
filepath_screen_rect,
open_button_screen_rect: None,
has_button_row: true,
});
}
}
}
// Diagram affordance rows: map each block-relative reserved row to a
// screen rect when visible. The blank row already scrolls with the
// content; the draw loop paints the buttons + registers click hit-rects.
// Agent messages (the only producer) have no top vpad, so `row_offset`
// is measured straight from `y_start`, like inline media above.
// Header gate unreachable today (producers are agent messages — run
// breakers, so never verb-group members either); structural.
let diagram_affordances = if is_group_header {
Vec::new()
} else {
entry.block.diagram_affordances(&ctx)
};
for aff in diagram_affordances {
let virtual_y = entry_start + aff.row_offset as usize;
let viewport_bottom = viewport_start + viewport.height as usize;
if virtual_y >= viewport_start && virtual_y < viewport_bottom {
result.diagram_affordances.push(DiagramAffordancePlacement {
screen_rect: ratatui::layout::Rect {
x: entry_row_layout.content.x,
y: viewport.y + (virtual_y - viewport_start) as u16,
width: content_width,
height: 1,
},
source: aff.source,
});
}
}
// Click targets for the text `[Open]` button + filepath of media blocks
// without an inline overlay (terminals without inline-graphics support).
if (!is_group_header || verb_expanded_slot)
&& let Some((open_path, is_video)) = entry.block.inline_open_button()
{
let content_lines = cached_output.lines.len();
let viewport_bottom = viewport_start + viewport.height as usize;
// Virtual-y coordinates are usize (tall scrollback); the resulting
// screen y is a viewport-relative offset that fits in u16.
let line_screen_rect =
|virtual_y: usize, width: u16| -> Option<ratatui::layout::Rect> {
if virtual_y >= viewport_start && virtual_y < viewport_bottom {
Some(ratatui::layout::Rect {
x: entry_content_area.x,
y: viewport.y + (virtual_y - viewport_start) as u16,
width,
height: 1,
})
} else {
None
}
};
// Filepath line (index 1) → click-to-copy.
let filepath_screen_rect =
line_screen_rect(content_y_start + 1, entry_content_area.width);
// Centered `[Open]` button → click-to-open. It is the second-to-last
// content line (the last line is a blank spacer).
let open_button_screen_rect = if content_lines >= 2 {
let label_w = media_open_button_label(is_video).len() as u16;
let col = media_open_button_col(content_width, is_video);
let button_virtual_y = content_y_start + (content_lines - 2);
line_screen_rect(button_virtual_y, label_w).map(|mut rect| {
rect.x = rect.x.saturating_add(col);
rect
})
} else {
None
};
if filepath_screen_rect.is_some() || open_button_screen_rect.is_some() {
result.inline_media.push(InlineMediaPlacement {
info: crate::prompt_images::InlineMediaInfo {
path: open_path,
width: 0,
height: 0,
is_video,
alt_text: String::new(),
},
screen_rect: ratatui::layout::Rect {
x: entry_content_area.x,
y: viewport.y,
width: 0,
height: 0,
},
full_rows: 0,
top_crop_rows: 0,
filepath_screen_rect,
open_button_screen_rect,
// Text-button placement: the button is the text [Open] line
// (`open_button_screen_rect`), not an image-overlay row.
has_button_row: false,
});
}
}
// Track selected entry
if selected_idx == Some(logical_idx) {
let selection_area = entry_row_layout.selection_area();
result.selected_area = Some(SelectedEntryArea {
area: selection_area,
top_clipped,
bottom_clipped,
});
}
y = entry_end + entry_layouts_cache[i].gap_after as usize;
}
ScrollRenderResultWithBoundaries {
result,
selection_boundaries,
}
}
use super::types::BlockOutput;
/// Map pre-wrap `HyperlinkTarget`s to screen-space `OverlayLink`s.
///
/// Walks the post-wrap `BlockOutput` lines, using joiner metadata to
/// reconstruct the pre-wrap → post-wrap line mapping. For each
/// hyperlink, finds the wrapped line(s) it overlaps and emits an
/// `OverlayLink` with the correct screen row and column offsets.
///
/// `content_line_offset` accounts for non-markdown header lines prepended
/// by block types like `BtwBlock` (header + separator) and `ThinkingBlock`
/// (header + blank when the header config is enabled).
///
/// Also used by the `/btw` inline panel (no header offset — pure markdown body).
#[allow(clippy::too_many_arguments)]
pub(crate) fn map_hyperlinks_to_overlay(
hyperlinks: &[kigi_markdown::HyperlinkTarget],
block_output: &BlockOutput,
content_skip: usize,
first_visible_screen_y: u16,
max_screen_y: u16,
content_x: u16,
content_line_offset: usize,
media_paths: &[std::path::PathBuf],
overlay: &mut LinkOverlay,
) {
// Build mapping: pre-wrap line index → vec of (wrapped_idx, col_start_in_prewrap, col_end_in_prewrap).
// A joiner of None means a new pre-wrap line starts.
let mut pre_wrap_segments: Vec<Vec<(usize, usize, usize)>> = Vec::new();
let mut current_segments: Vec<(usize, usize, usize)> = Vec::new();
let mut cumulative_col: usize = 0;
for (wrapped_idx, line) in block_output.lines.iter().enumerate() {
if line.joiner.is_none() && !current_segments.is_empty() {
pre_wrap_segments.push(std::mem::take(&mut current_segments));
cumulative_col = 0;
}
// Joiner represents the whitespace consumed at the wrap point —
// it occupies display columns in the pre-wrap line but doesn't
// appear in either wrapped line. Add BEFORE this segment so
// the column mapping stays aligned.
if let Some(ref joiner) = line.joiner {
cumulative_col += unicode_width::UnicodeWidthStr::width(joiner.as_str());
}
let line_width = line.content.width();
current_segments.push((wrapped_idx, cumulative_col, cumulative_col + line_width));
cumulative_col += line_width;
}
if !current_segments.is_empty() {
pre_wrap_segments.push(current_segments);
}
// Map each hyperlink to screen-space OverlayLinks. Unsafe schemes
// (javascript:, data:, …) are dropped since OSC 8 URLs reach the terminal
// without the link_opener scheme filter. A non-web destination may still be
// a local file the model linked (`[videos/1.mp4](videos/1.mp4)`): resolve it
// to a `file://` URL (relative paths matched against this transcript's
// generated media) so it opens like an absolute path.
let scheme_filter = crate::terminal::hyperlinks::SchemeFilter::Standard;
for h in hyperlinks {
let url: Arc<str> = if crate::app::link_opener::is_safe_to_open(&h.url, scheme_filter) {
Arc::from(h.url.as_str())
} else if let Some(file_url) =
crate::render::osc8::local_link_to_file_url(&h.url, media_paths)
{
file_url
} else {
continue;
};
let adjusted_line = h.line_index + content_line_offset;
if adjusted_line >= pre_wrap_segments.len() {
continue;
}
let segments = &pre_wrap_segments[adjusted_line];
for &(wrapped_idx, seg_col_start, seg_col_end) in segments {
// Check if hyperlink's column range overlaps this wrapped segment.
let overlap_start = h.column_range.start.max(seg_col_start);
let overlap_end = h.column_range.end.min(seg_col_end);
if overlap_start >= overlap_end {
continue;
}
// Check visibility (accounting for content_skip).
if wrapped_idx < content_skip {
continue;
}
let visible_offset = (wrapped_idx - content_skip) as u16;
let screen_row = first_visible_screen_y + visible_offset;
if screen_row >= max_screen_y {
continue;
}
let local_col_start = (overlap_start - seg_col_start) as u16;
let local_col_end = (overlap_end - seg_col_start) as u16;
overlay.push(OverlayLink {
screen_row,
col_start: content_x + local_col_start,
col_end: content_x + local_col_end,
url: Arc::clone(&url),
id: Some(h.id),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::appearance::AppearanceConfig;
use crate::scrollback::RenderBlock;
use crate::scrollback::block::BlockContent;
use crate::scrollback::types::DisplayMode;
use crate::scrollback::wrappers::EntryRenderer;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
fn make_entries(count: usize) -> Vec<ScrollbackEntry> {
(0..count)
.map(|i| ScrollbackEntry::new(RenderBlock::stub(format!("Entry {i}"), Color::Blue)))
.collect()
}
fn make_markdown_entry(text: &str) -> ScrollbackEntry {
ScrollbackEntry::new(RenderBlock::agent_message(text))
}
/// Compute EntryLayoutInfo for a set of entries (heights + gap_after).
/// Uses default appearance. Gap rule: all stubs are groupable+expanded → gap=1.
fn compute_layouts(
entries: &[ScrollbackEntry],
viewport_width: u16,
appearance: &AppearanceConfig,
) -> Vec<EntryLayoutInfo> {
let theme = Theme::current();
let layout = HorizontalLayout::new(
Rect::new(0, 0, viewport_width, 1),
&appearance.scrollback.layout,
);
let content_width = layout.entry_content_area().width;
let n = entries.len();
let mut layouts: Vec<EntryLayoutInfo> = entries
.iter()
.map(|e| {
let renderer = EntryRenderer::new(e, &theme).with_appearance(appearance.clone());
let height = renderer.desired_height(content_width);
EntryLayoutInfo {
height,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
} // placeholder
})
.collect();
// Compute gap_after using the pairwise rule
for i in 0..n.saturating_sub(1) {
let a_groupable = entries[i].block.is_groupable();
let b_groupable = entries[i + 1].block.is_groupable();
let a_collapsed = entries[i].display_mode == DisplayMode::Collapsed;
let b_collapsed = entries[i + 1].display_mode == DisplayMode::Collapsed;
layouts[i].gap_after = if a_groupable && b_groupable && a_collapsed && b_collapsed {
0
} else {
1
};
}
// Last entry: trailing gap = 1
if n > 0 {
layouts[n - 1].gap_after = 1;
}
layouts
}
/// Render entries using the production scratch path.
fn render_with_scratch(
entries: &[ScrollbackEntry],
viewport: Rect,
scroll_offset: usize,
selected_idx: Option<usize>,
) -> ScrollRenderResult {
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(entries, viewport.width, &appearance);
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
scroll_offset,
selected_idx,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
)
}
fn render_with_selection_boundaries(
entries: &[ScrollbackEntry],
viewport: Rect,
scroll_offset: usize,
) -> ScrollRenderResultWithBoundaries {
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(entries, viewport.width, &appearance);
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_selection_boundaries(
&mut buf,
viewport,
&refs,
scroll_offset,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
)
}
/// Render entries with a search-highlight regex and return the buffer.
fn render_with_highlight(
entries: &[ScrollbackEntry],
viewport: Rect,
re: Option<&regex::Regex>,
) -> Buffer {
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(entries, viewport.width, &appearance);
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
re,
0,
0,
&[],
None,
None,
);
buf
}
fn count_reversed(buf: &Buffer) -> usize {
(buf.area.top()..buf.area.bottom())
.map(|y| reversed_cols_on_row(buf, y).len())
.sum()
}
/// The plain text of buffer row `y` (viewport x starts at 0, so a byte
/// index into this string equals the buffer column for ASCII content).
fn buffer_row_text(buf: &Buffer, y: u16) -> String {
(buf.area.left()..buf.area.right())
.map(|x| buf[(x, y)].symbol())
.collect()
}
/// Buffer columns on row `y` whose cell carries the REVERSED modifier.
fn reversed_cols_on_row(buf: &Buffer, y: u16) -> Vec<u16> {
(buf.area.left()..buf.area.right())
.filter(|&x| {
buf[(x, y)]
.modifier
.contains(ratatui::style::Modifier::REVERSED)
})
.collect()
}
/// Columns covered by every occurrence of `needle` in `row` (ASCII only).
fn expected_match_cols(row: &str, needle: &str) -> Vec<u16> {
let mut cols = Vec::new();
let mut from = 0;
while let Some(rel) = row[from..].find(needle) {
let start = from + rel;
cols.extend((start as u16)..(start + needle.len()) as u16);
from = start + needle.len();
}
cols
}
#[test]
fn search_highlight_inverts_exactly_the_match_columns() {
let entries = vec![make_markdown_entry("alpha needle beta needle")];
let viewport = Rect::new(0, 0, 80, 10);
let re = regex::Regex::new("needle").unwrap();
let buf = render_with_highlight(&entries, viewport, Some(&re));
let y = (0..viewport.height)
.find(|&y| buffer_row_text(&buf, y).contains("needle"))
.expect("rendered row containing the text");
let row = buffer_row_text(&buf, y);
let expected = expected_match_cols(&row, "needle");
assert_eq!(expected.len(), 12, "two 6-char matches");
assert_eq!(
reversed_cols_on_row(&buf, y),
expected,
"exactly the matched glyph columns are inverted"
);
// No other row carries inverted cells.
assert_eq!(count_reversed(&buf), expected.len());
}
#[test]
fn search_highlight_maps_to_wrapped_continuation_row() {
// A narrow viewport forces the message to wrap; the match lands on a
// continuation row, exercising the per-row screen_y mapping.
let entries = vec![make_markdown_entry(
"alpha bravo charlie delta echo foxtrot golf hotel needle",
)];
// Width 40 wraps the sentence (message blocks also reserve 10 cols for
// the timestamp) while keeping "needle" whole on a continuation row.
let viewport = Rect::new(0, 0, 40, 12);
let re = regex::Regex::new("needle").unwrap();
let buf = render_with_highlight(&entries, viewport, Some(&re));
let y = (0..viewport.height)
.find(|&y| buffer_row_text(&buf, y).contains("needle"))
.expect("rendered row containing the match");
assert!(y > 0, "match wrapped onto a continuation row, not row 0");
let row = buffer_row_text(&buf, y);
assert_eq!(
reversed_cols_on_row(&buf, y),
expected_match_cols(&row, "needle"),
);
assert_eq!(count_reversed(&buf), 6, "only the single match is inverted");
}
#[test]
fn some_regex_with_no_match_inverts_nothing() {
let entries = vec![make_markdown_entry("alpha needle beta")];
let viewport = Rect::new(0, 0, 80, 10);
let re = regex::Regex::new("zzz").unwrap();
let buf = render_with_highlight(&entries, viewport, Some(&re));
assert_eq!(count_reversed(&buf), 0);
}
#[test]
fn no_highlight_regex_inverts_nothing() {
let entries = vec![make_markdown_entry("alpha needle beta")];
let viewport = Rect::new(0, 0, 80, 10);
let buf = render_with_highlight(&entries, viewport, None);
assert_eq!(count_reversed(&buf), 0);
}
#[test]
fn test_total_height_calculation() {
let entries = make_entries(3);
let viewport = Rect::new(0, 0, 80, 20);
let result = render_with_scratch(&entries, viewport, 0, None);
// Each stub entry: 3 lines (1 content + 2 vpad). All are groupable+expanded → gap=1.
// Total: 3*3 + 2 gaps + 1 trailing = 12
assert_eq!(result.total_height, 12);
}
#[test]
fn test_scroll_offset_skips_content() {
let entries = make_entries(5);
let viewport = Rect::new(0, 0, 80, 10);
let result = render_with_scratch(&entries, viewport, 4, None);
// 5 entries * 3 lines + 4 gaps + 1 trailing = 20
assert_eq!(result.total_height, 5 * 3 + 5);
}
/// Large scrollback mid-offset: shipped render + viewport paint window must
/// match a full-list pass (geometry / selection / total_height of full list).
#[test]
fn large_scrollback_mid_offset_viewport_window_matches_full_pass() {
const N: usize = 3000;
let entries = make_entries(N);
let viewport = Rect::new(0, 0, 80, 24);
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(&entries, viewport.width, &appearance);
// Prefix virtual_y as LayoutCache does (absolute y of each entry start).
let mut virtual_y = Vec::with_capacity(N);
let mut y = 0usize;
for layout in &layouts {
virtual_y.push(y);
y += layout.height as usize + layout.gap_after as usize;
}
let full_total = y;
// Mid-session offset: past thousands of entries, not at top/bottom.
let scroll_offset = full_total / 2;
let selected_abs = virtual_y
.partition_point(|&vy| vy <= scroll_offset)
.saturating_sub(1)
.min(N - 1);
// Full-list baseline (content_y0=0, index_base=0).
let full_refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let mut full_buf = Buffer::empty(viewport);
let full = render_scrolled_entries_with_scratch(
&mut full_buf,
viewport,
&full_refs,
scroll_offset,
Some(selected_abs),
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
// Production window: the same helper `render_content` paints through.
let (paint_range, content_y0) = crate::scrollback::state::compute_paint_window(
&virtual_y,
&layouts,
0..N,
scroll_offset,
viewport.height as usize,
|_| unreachable!("plain stub entries form no verb-group runs"),
);
let (first, end) = (paint_range.start, paint_range.end);
assert!(
end - first < N / 10,
"paint window should be << total history: first={first} end={end} N={N}"
);
let window_refs: Vec<&ScrollbackEntry> = entries[first..end].iter().collect();
let mut win_buf = Buffer::empty(viewport);
let windowed = render_scrolled_entries_with_scratch(
&mut win_buf,
viewport,
&window_refs,
scroll_offset,
Some(selected_abs),
&theme,
&appearance,
&layouts[first..end],
0,
None,
None,
None,
content_y0,
first,
&[],
None,
None,
);
assert_eq!(full.total_height, full_total);
let full_sel = full
.selected_area
.as_ref()
.expect("selected visible (full)");
let win_sel = windowed
.selected_area
.as_ref()
.expect("selected visible (window)");
assert_eq!(full_sel.area, win_sel.area);
assert_eq!(full_sel.top_clipped, win_sel.top_clipped);
assert_eq!(full_sel.bottom_clipped, win_sel.bottom_clipped);
assert_eq!(
full.selection_model.visible_blocks.len(),
windowed.selection_model.visible_blocks.len()
);
for (a, b) in full
.selection_model
.visible_blocks
.iter()
.zip(windowed.selection_model.visible_blocks.iter())
{
assert_eq!(a.entry_idx, b.entry_idx);
assert_eq!(a.area, b.area);
assert_eq!(a.top_clipped, b.top_clipped);
assert_eq!(a.bottom_clipped, b.bottom_clipped);
}
// Cell content must match — proves paint of the window equals full walk.
assert_eq!(full_buf, win_buf);
}
/// A verb-group header on the viewport's last row must paint the label of
/// the FULL run (count + failure suffix), not just the on-screen members:
/// `paint_window` extends the slice past the window bottom so the label
/// walk sees every member.
#[test]
fn windowed_paint_renders_full_verb_group_label_for_offscreen_members() {
use crate::scrollback::ScrollbackState;
use crate::scrollback::blocks::tool::{ReadToolCallBlock, ToolCallBlock};
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
for i in 0..20 {
state.push_block(RenderBlock::agent_message(format!("filler {i}")));
}
let header = state.len();
for i in 0..48 {
state.push_block(RenderBlock::read(format!("f{i}.rs"), None));
}
for i in 0..2 {
state.push_block(RenderBlock::ToolCall(ToolCallBlock::Read(
ReadToolCallBlock::new(format!("gone{i}.rs")).with_error("no such file"),
)));
}
let viewport = Rect::new(0, 0, 80, 24);
state.prepare_layout(viewport.width, viewport.height);
let virtual_y = state.get_cached_virtual_y().expect("layout cache");
let layouts = state.get_cached_entry_layouts().expect("layout cache");
// Header row on the viewport's last row: all 50 members are off-screen.
let scroll = virtual_y[header] + 1 - viewport.height as usize;
let (paint_range, content_y0) =
state.paint_window(0..state.len(), scroll, viewport.height as usize);
assert_eq!(
paint_range.end,
header + 50,
"window must cover the whole run"
);
let window = state.entries_in_range(paint_range.clone());
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&window,
scroll,
None,
&Theme::current(),
state.appearance(),
&layouts[paint_range.clone()],
0,
None,
None,
None,
content_y0,
paint_range.start,
&[],
None,
None,
);
let header_row = buffer_row_text(&buf, viewport.height - 1);
assert!(
header_row.contains("Read 50 files · 2 failed"),
"header label must aggregate the full off-screen run: {header_row:?}"
);
}
/// A collapsed truncation header on the viewport's last row must label
/// its FULL hidden prefix: the height-0 hidden rows share the tail's
/// virtual_y past the window bottom, so `paint_window`'s gate must
/// extend the slice for truncation headers too — not just verb headers.
#[test]
fn windowed_paint_labels_truncation_header_on_last_viewport_row() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
state.set_appearance(appearance);
for i in 0..20 {
state.push_block(RenderBlock::agent_message(format!("filler {i}")));
}
let header = state.len();
for i in 0..6 {
state.push_block(RenderBlock::execute(format!("cmd{i}")));
}
let viewport = Rect::new(0, 0, 80, 24);
state.prepare_layout(viewport.width, viewport.height);
let virtual_y = state.get_cached_virtual_y().expect("layout cache");
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(!layouts[header].verb_group_header, "truncation, not verb");
assert_eq!(layouts[header].group_header_count, 2);
// Header row on the viewport's last row: the hidden prefix and tail
// sit past the window bottom.
let scroll = virtual_y[header] + 1 - viewport.height as usize;
let (paint_range, content_y0) =
state.paint_window(0..state.len(), scroll, viewport.height as usize);
assert_eq!(
paint_range.end,
header + 6,
"window must cover the whole run"
);
let window = state.entries_in_range(paint_range.clone());
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&window,
scroll,
None,
&Theme::current(),
state.appearance(),
&layouts[paint_range.clone()],
0,
None,
None,
None,
content_y0,
paint_range.start,
&[],
Some((state.group_spans(), paint_range.start)),
None,
);
let header_row = buffer_row_text(&buf, viewport.height - 1);
assert!(
header_row.contains("Ran 3 commands"),
"label must cover the full off-screen hidden prefix: {header_row:?}"
);
}
/// A hidden thinking entry inside a folded run stays transparent through
/// the whole production path: the layout fold spans it AND the rendered
/// header label counts the members on both sides — pinning the
/// `show_thinking` handoff from the render loop into the label walk.
#[test]
fn rendered_verb_group_label_spans_hidden_thinking_inside_folded_run() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
state.push_block(RenderBlock::read("a.rs", None));
state.push_block(RenderBlock::thinking("hmm"));
state.push_block(RenderBlock::read("b.rs", None));
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(
layouts[0].verb_group_header,
"fold must span the hidden thinking entry"
);
let refs = state.entries_in_range(0..state.len());
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&Theme::current(),
state.appearance(),
layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let header_row = buffer_row_text(&buf, 0);
assert!(
header_row.contains("Read 2 files"),
"label must count members on both sides of hidden thinking: {header_row:?}"
);
}
/// A finished collapsed thought inside a folded run stays out of the
/// collapsed header entirely: the fold claims it (no standalone
/// "Thought" row anywhere) while the rendered label counts tools only.
#[test]
fn rendered_verb_group_label_stays_tools_only_across_folded_thought() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(true);
let mut state = ScrollbackState::new();
state.push_block(RenderBlock::read("a.rs", None));
let thought = state.push_block(RenderBlock::thinking("weighed the options"));
state
.get_by_id_mut(thought)
.unwrap()
.set_display_mode(DisplayMode::Collapsed);
state.push_block(RenderBlock::read("b.rs", None));
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(layouts[0].verb_group_header, "run folds across the thought");
assert_eq!(layouts[1].height, 0, "the thought claims into the fold");
let refs = state.entries_in_range(0..state.len());
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&Theme::current(),
state.appearance(),
layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let header_row = buffer_row_text(&buf, 0);
assert!(
header_row.contains("Read 2 files"),
"label must stay tools-only: {header_row:?}"
);
for y in 0..viewport.height {
let row = buffer_row_text(&buf, y);
assert!(
!row.contains("Thought") && !row.contains("weighed"),
"no folded-thought row may render (row {y}): {row:?}"
);
}
}
/// A single groupable tool call folds on its own: the run renders the
/// aggregated header label — not the tool's own row — and finished
/// thoughts fold behind it just like in a multi-member run.
#[test]
fn rendered_verb_group_singleton_folds_tool_and_trailing_thoughts() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(true);
let mut state = ScrollbackState::new();
state.push_block(RenderBlock::list_dir("src"));
for text in ["scanned the tree", "picked a file"] {
let thought = state.push_block(RenderBlock::thinking(text));
state
.get_by_id_mut(thought)
.unwrap()
.set_display_mode(DisplayMode::Collapsed);
}
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(layouts[0].verb_group_header, "singleton run folds");
assert_eq!(layouts[1].height, 0, "thoughts claim into the fold");
assert_eq!(layouts[2].height, 0, "thoughts claim into the fold");
let refs = state.entries_in_range(0..state.len());
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&Theme::current(),
state.appearance(),
layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let header_row = buffer_row_text(&buf, 0);
assert!(
header_row.contains("Listed 1 dir"),
"singleton header must render the aggregated label: {header_row:?}"
);
for y in 0..viewport.height {
let row = buffer_row_text(&buf, y);
assert!(
!row.contains("List src") && !row.contains("Thought"),
"neither the raw tool row nor a thought row may render (row {y}): {row:?}"
);
}
}
/// A subagent lifecycle row folds into the verb group: the collapsed
/// header renders the aggregated label, and expanding the group reveals
/// the subagent's own row with its live ` — activity` suffix intact.
#[test]
fn rendered_verb_group_folds_subagent_row_and_expansion_keeps_activity() {
use crate::scrollback::ScrollbackState;
use crate::scrollback::blocks::SubagentBlock;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
state.push_block(RenderBlock::read("a.rs", None));
let mut block = SubagentBlock::started(
"task", "child-A", "explore", None, None, None, /*is_background=*/ false,
);
block.activity_label = Some("Thinking".to_string());
state.push_block(RenderBlock::Subagent(block));
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(layouts[0].verb_group_header, "run folds with the subagent");
assert_eq!(layouts[1].height, 0, "subagent row claims into the fold");
let refs = state.entries_in_range(0..state.len());
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&Theme::current(),
state.appearance(),
layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let header_row = buffer_row_text(&buf, 0);
assert!(
header_row.contains("Read 1 file, Ran 1 subagent"),
"header must aggregate tool and subagent members: {header_row:?}"
);
// Expanded: the subagent member surfaces with its live suffix.
state.set_selected(Some(0));
assert!(state.toggle_group_expansion());
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
let refs = state.entries_in_range(0..state.len());
let mut buf = Buffer::empty(viewport);
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&Theme::current(),
state.appearance(),
layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let member_rows: Vec<String> = (0..viewport.height)
.map(|y| buffer_row_text(&buf, y))
.collect();
assert!(
member_rows
.iter()
.any(|r| r.contains("Subagent") && r.contains("\u{2014} Thinking")),
"expanded member row must keep the activity suffix: {member_rows:?}"
);
}
/// Render a prepared state — feeding the fold's spans when `with_spans` —
/// and return the frame buffer plus the full render result (selection
/// model included). The span-less path is what harnesses exercise and
/// must keep the legacy plain-count header text.
fn render_state(
state: &crate::scrollback::ScrollbackState,
viewport: Rect,
with_spans: bool,
) -> (Buffer, ScrollRenderResult) {
let layouts = state.get_cached_entry_layouts().expect("layout cache");
let refs = state.entries_in_range(0..state.len());
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&Theme::current(),
state.appearance(),
layouts,
0,
None,
None,
None,
0,
0,
&[],
with_spans.then(|| (state.group_spans(), 0usize)),
None,
);
(buf, result)
}
/// Render the header row (row 0) of a truncation-grouped state, with or
/// without the fold's spans.
fn truncation_header_row(
state: &crate::scrollback::ScrollbackState,
viewport: Rect,
with_spans: bool,
) -> String {
buffer_row_text(&render_state(state, viewport, with_spans).0, 0)
}
/// A collapsed truncation ("N more") header fed with the fold's spans
/// renders the aggregated bucket label for exactly its hidden prefix —
/// the header row plus the rows folded behind it — while the span-less
/// path keeps the plain count.
#[test]
fn truncation_header_renders_bucket_label_with_spans_and_plain_count_without() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
state.set_appearance(appearance);
for i in 0..6 {
state.push_block(RenderBlock::execute(format!("cmd{i}")));
}
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(!layouts[0].verb_group_header, "commands never verb-fold");
// 6 participants, max_visible=3 → hidden=3; the plain count shows
// hidden-1 while the label describes all 3 hidden participants.
assert_eq!(layouts[0].group_header_count, 2);
let labeled = truncation_header_row(&state, viewport, true);
assert!(
labeled.contains("Ran 3 commands"),
"spans feed the hidden-prefix bucket label: {labeled:?}"
);
let plain = truncation_header_row(&state, viewport, false);
assert!(
plain.contains("2 more"),
"span-less render keeps the legacy count: {plain:?}"
);
}
/// The aggregated vocabulary is owned by the "Group tool calls" setting:
/// toggled off, a span-fed truncation header keeps the plain "N more"
/// count (with the toggle off, previously verb-groupable tools feed
/// truncation runs — they must not come back verb-labeled anyway).
#[test]
fn truncation_header_keeps_plain_count_when_group_tool_verbs_off() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(false);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
state.set_appearance(appearance);
for i in 0..6 {
state.push_block(RenderBlock::execute(format!("cmd{i}")));
}
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert_eq!(layouts[0].group_header_count, 2, "run still truncates");
let row = truncation_header_row(&state, viewport, true);
assert!(
row.contains("2 more") && !row.contains("Ran"),
"toggle off keeps the legacy count even with spans: {row:?}"
);
}
/// The expanded truncation collapse header describes the WHOLE run —
/// every participant, visible tail included — when spans are present,
/// and keeps the legacy "N tool calls & thoughts" count without them.
#[test]
fn expanded_truncation_collapse_header_renders_whole_run_label() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
state.set_appearance(appearance);
for i in 0..6 {
state.push_block(RenderBlock::execute(format!("cmd{i}")));
}
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
state.set_selected(Some(0));
assert!(state.toggle_group_expansion());
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(layouts[0].group_collapse_header);
assert_eq!(layouts[0].group_header_count, 5);
let labeled = truncation_header_row(&state, viewport, true);
assert!(
labeled.contains("Ran 6 commands"),
"expanded header must describe the whole run: {labeled:?}"
);
let plain = truncation_header_row(&state, viewport, false);
assert!(
plain.contains("5 tool calls & thoughts"),
"span-less render keeps the legacy count: {plain:?}"
);
}
/// A same-row drag spanning a line's full selectable width, for copy
/// reconstruction assertions.
fn full_row_drag(
line: &ResolvedSelectableLine,
) -> crate::scrollback::text_selection::ActiveTextDrag {
use crate::scrollback::text_selection::{ActiveTextDrag, RangeHit};
let hit = RangeHit {
entry_idx: line.entry_idx,
range_id: line.range_id,
block_line_idx: line.block_line_idx,
col_within_range: 0,
};
ActiveTextDrag {
anchor: hit,
head: RangeHit {
col_within_range: line.selectable_cols.end.saturating_sub(1),
..hit
},
kind: Default::default(),
anchor_content_width: None,
}
}
/// A labeled truncation header gets copy parity with verb headers: its
/// synthetic selectable row carries the aggregated label as the copy
/// text, hitbox shifted past the diamond chrome, in both fold states.
#[test]
fn labeled_truncation_header_synthetic_line_copies_label_text() {
use crate::scrollback::ScrollbackState;
use crate::scrollback::text_selection::reconstruct_selection_text;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
state.set_appearance(appearance);
for i in 0..6 {
state.push_block(RenderBlock::execute(format!("cmd{i}")));
}
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
// Collapsed: the header row copies its hidden-prefix label.
let (buf, result) = render_state(&state, viewport, true);
let model = &result.selection_model;
let header = model.range(0, GROUP_HEADER_RANGE_ID).expect("header range");
assert_eq!(header.lines.len(), 1);
assert_eq!(header.lines[0].text, "Ran 3 commands");
assert_eq!(header.lines[0].screen_y, 0);
// Pin the hitbox to the DRAWN glyphs, not just to the chrome helper:
// the frame's own cells must spell the label starting at screen_x, so
// a chrome edit that misaligned highlight from paint would fail here.
let screen_x = header.lines[0].screen_x;
let drawn: String = (screen_x..screen_x + header.lines[0].selectable_cols.end)
.map(|x| buf[(x, 0)].symbol())
.collect();
assert_eq!(
drawn, "Ran 3 commands",
"synthetic hitbox must start exactly where the drawn label starts"
);
let expected_x = HorizontalLayout::ACCENT
+ state.appearance().scrollback.layout.block_pad_left
+ group_header_chrome_prefix_width();
assert_eq!(
screen_x, expected_x,
"hitbox sits past accent chrome + diamond prefix"
);
let copy = reconstruct_selection_text(model, &full_row_drag(&header.lines[0]))
.expect("header copy");
assert_eq!(copy, "Ran 3 commands");
// Expanded: the collapse header describes the whole run and copies it.
state.set_selected(Some(0));
assert!(state.toggle_group_expansion());
state.prepare_layout(viewport.width, viewport.height);
let (_, result) = render_state(&state, viewport, true);
let model = &result.selection_model;
let header = model
.range(0, GROUP_HEADER_RANGE_ID)
.expect("expanded header range");
assert_eq!(header.lines[0].text, "Ran 6 commands");
let copy = reconstruct_selection_text(model, &full_row_drag(&header.lines[0]))
.expect("expanded header copy");
assert_eq!(copy, "Ran 6 commands");
}
/// With the vocabulary gate off, a span-fed truncation header keeps the
/// plain count AND stays non-copyable: no synthetic selectable line may
/// land on the header row (the span-less path is pinned by
/// `group_header_entry_contributes_no_selectable_lines`).
#[test]
fn plain_count_truncation_header_contributes_no_selectable_line() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(false);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
state.set_appearance(appearance);
for i in 0..6 {
state.push_block(RenderBlock::execute(format!("cmd{i}")));
}
let viewport = Rect::new(0, 0, 80, 10);
state.prepare_layout(viewport.width, viewport.height);
let (buf, result) = render_state(&state, viewport, true);
assert!(
buffer_row_text(&buf, 0).contains("2 more"),
"gate off keeps the plain count"
);
assert!(
result
.selection_model
.range(0, GROUP_HEADER_RANGE_ID)
.is_none(),
"plain-count header must not gain the synthetic label range"
);
let rows: Vec<u16> = result
.selection_model
.ranges
.iter()
.flat_map(|r| r.lines.iter())
.map(|l| l.screen_y)
.collect();
assert!(
rows.iter().all(|row| *row != 0),
"no selectable line may land on the plain '◈ N more' header row, got: {rows:?}"
);
}
/// Both fold families feed the single label channel in one frame: the
/// verb header and the labeled truncation header each render their
/// aggregated label and expose it through the shared reserved range.
#[test]
fn verb_and_truncation_headers_share_one_label_channel() {
use crate::scrollback::ScrollbackState;
crate::appearance::cache::set_group_tool_verbs(true);
crate::appearance::cache::set_show_thinking_blocks(false);
let mut state = ScrollbackState::new();
let mut appearance = AppearanceConfig::default();
appearance.scrollback.display.group_max_visible = 3;
state.set_appearance(appearance);
state.push_block(RenderBlock::read("a.rs", None));
state.push_block(RenderBlock::read("b.rs", None));
for i in 0..6 {
state.push_block(RenderBlock::execute(format!("cmd{i}")));
}
let viewport = Rect::new(0, 0, 80, 12);
state.prepare_layout(viewport.width, viewport.height);
let layouts = state.get_cached_entry_layouts().expect("layout cache");
assert!(layouts[0].verb_group_header, "reads verb-fold");
assert!(
!layouts[2].verb_group_header && layouts[2].is_group_header(),
"commands truncation-fold behind the verb run"
);
let (buf, result) = render_state(&state, viewport, true);
let rows: Vec<String> = (0..viewport.height)
.map(|y| buffer_row_text(&buf, y))
.collect();
assert!(
rows.iter().any(|r| r.contains("Read 2 files")),
"verb header must render its label: {rows:?}"
);
assert!(
rows.iter().any(|r| r.contains("Ran 3 commands")),
"truncation header must render its label: {rows:?}"
);
let model = &result.selection_model;
let verb = model
.range(0, GROUP_HEADER_RANGE_ID)
.expect("verb header range");
assert_eq!(verb.lines[0].text, "Read 2 files");
let trunc = model
.range(2, GROUP_HEADER_RANGE_ID)
.expect("truncation header range");
assert_eq!(trunc.lines[0].text, "Ran 3 commands");
}
#[test]
fn test_selected_entry_tracking() {
let entries = make_entries(3);
let viewport = Rect::new(0, 0, 80, 20);
let result = render_with_scratch(&entries, viewport, 0, Some(1));
assert!(result.selected_area.is_some());
let selected = result.selected_area.unwrap();
assert!(!selected.top_clipped);
assert!(!selected.bottom_clipped);
}
#[test]
fn test_partial_visibility_top_clipped() {
let entries = make_entries(3);
let viewport = Rect::new(0, 0, 80, 10);
let result = render_with_scratch(&entries, viewport, 1, Some(0));
assert!(result.selected_area.is_some());
let selected = result.selected_area.unwrap();
assert!(selected.top_clipped);
assert!(!selected.bottom_clipped);
}
#[test]
fn test_selection_model_maps_visible_lines_for_markdown_entry() {
let entries = vec![make_markdown_entry(
"hello world this should wrap across lines",
)];
let viewport = Rect::new(0, 0, 20, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
assert!(!result.selection_model.ranges.is_empty());
let range = &result.selection_model.ranges[0];
assert_eq!(range.entry_idx, 0);
assert!(range.lines.len() > 1);
assert!(
range
.lines
.windows(2)
.all(|w| w[0].screen_y < w[1].screen_y)
);
}
#[test]
fn test_selection_model_top_clipped_markdown_entry() {
let entries = vec![make_markdown_entry(
"hello world this should wrap across lines",
)];
let viewport = Rect::new(0, 0, 20, 3);
let result = render_with_scratch(&entries, viewport, 1, None);
let range = &result.selection_model.ranges[0];
assert_eq!(range.lines[0].screen_y, 0);
}
#[test]
fn test_selection_model_bottom_clipped_markdown_entry() {
let entries = vec![make_markdown_entry(
"hello world this should wrap across lines",
)];
let viewport = Rect::new(0, 0, 20, 2);
let result = render_with_scratch(&entries, viewport, 0, None);
let range = &result.selection_model.ranges[0];
assert!(range.lines.len() <= 2);
}
#[test]
fn test_vpad_rows_do_not_become_selectable_lines() {
let entries = vec![ScrollbackEntry::new(RenderBlock::stub(
"hello",
Color::Blue,
))];
let viewport = Rect::new(0, 0, 20, 5);
let result = render_with_scratch(&entries, viewport, 0, None);
assert!(result.selection_model.ranges.is_empty());
}
#[test]
fn test_selected_entry_output_divergence_uses_selected_branch() {
let entries = vec![make_markdown_entry("hello")];
let viewport = Rect::new(0, 0, 20, 5);
let result = render_with_scratch(&entries, viewport, 0, Some(0));
assert!(!result.selection_model.visible_blocks.is_empty());
assert_eq!(result.selection_model.visible_blocks[0].entry_idx, 0);
}
/// Message-style blocks (`AgentMessage`, `UserPrompt`, `Btw`)
/// reserve 10 columns on the right for the timestamp overlay, so their cached
/// output is wrapped at `content_area.width - 10`, not `content_area.width`.
///
/// `VisibleBlockGeometry.content_width` must report the same reduced width
/// that was used to populate the cache; otherwise any code that re-derives
/// the wrapped lines from the model (notably `finish_text_drag`) would call
/// `effective_output` at the wrong width, get a different wrapping, and
/// slice the wrong content for the clipboard.
#[test]
fn message_block_content_width_subtracts_timestamp_reservation() {
// Picked so the message wraps to a different line count at
// `content_width - 10` than at `content_width`. With viewport=30
// and chrome=4, pane_content_width=26 and per-block content_width=16.
let entries = vec![make_markdown_entry(
"hello world foo bar baz qux quux corge grault garply waldo",
)];
let viewport = Rect::new(0, 0, 30, 20);
let result = render_with_scratch(&entries, viewport, 0, None);
let pane_content_width = result.selection_model.content_area.width;
let block = &result.selection_model.visible_blocks[0];
assert_eq!(
block.content_width,
pane_content_width.saturating_sub(10),
"AgentMessage should reserve 10 cols for the timestamp"
);
// The lines registered in the resolved model came from the cached
// output computed at `block.content_width`. Re-deriving them at the
// same width must produce the same line count so block_line_idx values
// remain valid; deriving at the wider `pane_content_width` produces a
// different wrapping (the bug `finish_text_drag` previously triggered).
let appearance = AppearanceConfig::default();
let model_lines = result.selection_model.ranges[0].lines.len();
let entry_lines_narrow = entries[0]
.effective_output(block.content_width, &appearance, false, None)
.output()
.lines
.len();
let entry_lines_wide = entries[0]
.effective_output(pane_content_width, &appearance, false, None)
.output()
.lines
.len();
assert_eq!(
entry_lines_narrow, model_lines,
"model lines must match a re-derivation at the per-block content_width"
);
assert_ne!(
entry_lines_wide, model_lines,
"wider width must wrap differently — proves passing content_area.width \
to effective_output would break block_line_idx alignment"
);
}
// ── map_hyperlinks_to_overlay ──
use crate::scrollback::types::BlockLine;
use ratatui::text::Line as RatatuiLine;
fn make_block_output(specs: &[(&str, Option<&str>)]) -> BlockOutput {
BlockOutput {
lines: specs
.iter()
.map(|(text, joiner)| {
let mut bl = BlockLine::styled(RatatuiLine::from(text.to_string()));
bl.joiner = joiner.map(|s| s.to_string());
bl
})
.collect(),
}
}
fn make_hyperlink(
line: usize,
cols: std::ops::Range<usize>,
url: &str,
id: u32,
) -> kigi_markdown::HyperlinkTarget {
kigi_markdown::HyperlinkTarget {
line_index: line,
column_range: cols,
url: url.to_string(),
id,
}
}
#[test]
fn overlay_single_line_link() {
let output = make_block_output(&[("hello world", None)]);
let links = [make_hyperlink(0, 0..5, "https://a.com", 1)];
let mut overlay = LinkOverlay::new();
map_hyperlinks_to_overlay(&links, &output, 0, 10, 20, 4, 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
let link = &overlay.links()[0];
assert_eq!(link.screen_row, 10);
assert_eq!(link.col_start, 4);
assert_eq!(link.col_end, 9);
assert_eq!(&*link.url, "https://a.com");
assert_eq!(link.id, Some(1));
}
#[test]
fn overlay_markdown_relative_link_opens_as_file_url() {
// End-to-end through the real render path: a markdown link to a short
// media path that matches this transcript's generated media becomes a
// `file://` overlay.
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("images")).unwrap();
std::fs::write(dir.path().join("images/1.png"), b"x").unwrap();
let media = vec![dir.path().join("images/1.png")];
let mut entries = vec![make_markdown_entry("Saved [images/1.png](images/1.png)\n")];
if let RenderBlock::AgentMessage(b) = &mut entries[0].block {
b.finish();
}
let viewport = Rect::new(0, 0, 80, 10);
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(&entries, viewport.width, &appearance);
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&media,
None,
None,
);
let links: Vec<&str> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.collect();
assert!(
links
.iter()
.any(|u| u.starts_with("file://") && u.ends_with("/images/1.png")),
"expected a file:// link to images/1.png, got {links:?}",
);
}
#[test]
fn overlay_word_wrap_splits_link() {
// Pre-wrap line "hello world" (11 chars) wrapped into two segments:
// wrapped line 0: "hello" (5 chars, joiner=None → new pre-wrap line)
// wrapped line 1: "world" (5 chars, joiner=Some(" ") → continuation)
// The joiner " " is the space consumed at the wrap point (col 5).
let output = make_block_output(&[("hello", None), ("world", Some(" "))]);
// Link spans the full pre-wrap line: cols 0..11
let links = [make_hyperlink(0, 0..11, "https://b.com", 2)];
let mut overlay = LinkOverlay::new();
map_hyperlinks_to_overlay(&links, &output, 0, 0, 10, 0, 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 2);
// First segment: cols 0..5 on screen row 0
assert_eq!(overlay.links()[0].screen_row, 0);
assert_eq!(overlay.links()[0].col_start, 0);
assert_eq!(overlay.links()[0].col_end, 5);
// Second segment: cols 0..5 on screen row 1
assert_eq!(overlay.links()[1].screen_row, 1);
assert_eq!(overlay.links()[1].col_start, 0);
assert_eq!(overlay.links()[1].col_end, 5);
assert_eq!(&*overlay.links()[0].url, &*overlay.links()[1].url);
}
#[test]
fn overlay_content_skip_hides_scrolled_lines() {
let output = make_block_output(&[("line0", None), ("line1", None), ("line2", None)]);
// Link on line 0 (scrolled off), link on line 2 (visible)
let links = [
make_hyperlink(0, 0..5, "https://hidden.com", 1),
make_hyperlink(2, 0..5, "https://visible.com", 2),
];
let mut overlay = LinkOverlay::new();
// content_skip=2 means first 2 wrapped lines are above viewport
map_hyperlinks_to_overlay(&links, &output, 2, 0, 10, 0, 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "https://visible.com");
assert_eq!(overlay.links()[0].screen_row, 0);
}
#[test]
fn overlay_max_screen_y_clips_links() {
let output = make_block_output(&[("line0", None), ("line1", None), ("line2", None)]);
let links = [
make_hyperlink(0, 0..5, "https://visible.com", 1),
make_hyperlink(2, 0..5, "https://clipped.com", 2),
];
let mut overlay = LinkOverlay::new();
// max_screen_y=2 → only rows 0..2 are visible (screen_y 0 and 1)
map_hyperlinks_to_overlay(&links, &output, 0, 0, 2, 0, 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(&*overlay.links()[0].url, "https://visible.com");
}
#[test]
fn overlay_content_line_offset_skips_header_lines() {
// Simulates BtwBlock: header + separator + markdown body
let output = make_block_output(&[
("/btw question", None), // header (offset 0)
("", None), // separator (offset 1)
("body text", None), // markdown body line 0
]);
// Hyperlink on markdown body line_index=0, cols 0..4
let links = [make_hyperlink(0, 0..4, "https://body.com", 1)];
let mut overlay = LinkOverlay::new();
// content_line_offset=2 shifts line_index by 2
map_hyperlinks_to_overlay(&links, &output, 0, 0, 10, 0, 2, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
assert_eq!(overlay.links()[0].screen_row, 2);
assert_eq!(overlay.links()[0].col_start, 0);
assert_eq!(overlay.links()[0].col_end, 4);
}
#[test]
fn overlay_content_line_offset_out_of_range_is_safe() {
let output = make_block_output(&[("only line", None)]);
// line_index=0, but offset=5 pushes adjusted_line=5 past pre_wrap_segments
let links = [make_hyperlink(0, 0..4, "https://x.com", 1)];
let mut overlay = LinkOverlay::new();
map_hyperlinks_to_overlay(&links, &output, 0, 0, 10, 0, 5, &[], &mut overlay);
assert!(overlay.is_empty());
}
#[test]
fn overlay_partial_column_overlap() {
// Link spans cols 3..8, but the line is only 6 chars wide (cols 0..6)
let output = make_block_output(&[("abcdef", None)]);
let links = [make_hyperlink(0, 3..8, "https://partial.com", 1)];
let mut overlay = LinkOverlay::new();
map_hyperlinks_to_overlay(&links, &output, 0, 5, 10, 2, 0, &[], &mut overlay);
assert_eq!(overlay.links().len(), 1);
let link = &overlay.links()[0];
assert_eq!(link.screen_row, 5);
assert_eq!(link.col_start, 5); // content_x(2) + local_col_start(3)
assert_eq!(link.col_end, 8); // content_x(2) + local_col_end(6)
}
#[test]
fn overlay_empty_hyperlinks_produces_nothing() {
let output = make_block_output(&[("text", None)]);
let links: &[kigi_markdown::HyperlinkTarget] = &[];
let mut overlay = LinkOverlay::new();
map_hyperlinks_to_overlay(links, &output, 0, 0, 10, 0, 0, &[], &mut overlay);
assert!(overlay.is_empty());
}
// ── URL scanning for non-markdown blocks ──
#[test]
fn execute_block_urls_get_overlay_links() {
let entries = vec![ScrollbackEntry::new(RenderBlock::execute_with_output(
"curl https://api.example.com/data",
"HTTP/1.1 200 OK\nSee https://docs.example.com/api for details.",
None::<String>,
))];
// Force expanded mode so output is visible.
let mut entries = entries;
entries[0].display_mode = DisplayMode::Expanded;
let viewport = Rect::new(0, 0, 80, 20);
let result = render_with_scratch(&entries, viewport, 0, None);
// Should find URL(s) in the output text.
assert!(
!result.link_overlay.is_empty(),
"execute block output should have linkified URLs"
);
let urls: Vec<&str> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.collect();
assert!(
urls.contains(&"https://docs.example.com/api"),
"expected URL from stdout in overlay, got: {:?}",
urls,
);
}
#[test]
fn markdown_wrapped_session_media_path_fully_linkified() {
// Regression: media-tool prose whose long session path soft-wraps
// across rows. The whole path must be clickable (one overlay region
// per row, all pointing at the full file:// URL) — not just the
// leading path fragment on the first row.
let path = "/Users/alice/.kigi/sessions/%2FUsers%2Falice%2Fcode%2Fxai/\
019e0000-0000-7000-8000-000000000001/images/1.jpg";
let entries = vec![make_markdown_entry(&format!(
"Image generated and saved to {path}\n"
))];
// Narrow viewport so the path wraps across several rows.
let viewport = Rect::new(0, 0, 40, 20);
let result = render_with_scratch(&entries, viewport, 0, None);
let expected_url = url::Url::from_file_path(path).unwrap();
let path_links: Vec<_> = result
.link_overlay
.links()
.iter()
.filter(|l| &*l.url == expected_url.as_str())
.collect();
assert!(
path_links.len() >= 2,
"wrapped path should yield one overlay region per visual row, got: {:?}",
result
.link_overlay
.links()
.iter()
.map(|l| (&*l.url, l.screen_row, l.col_start, l.col_end))
.collect::<Vec<_>>()
);
// Regions land on consecutive distinct rows.
let mut rows: Vec<u16> = path_links.iter().map(|l| l.screen_row).collect();
rows.dedup();
assert_eq!(
rows.len(),
path_links.len(),
"each visual row gets one region"
);
assert!(rows.windows(2).all(|w| w[1] == w[0] + 1));
}
#[test]
fn markdown_block_does_not_double_scan() {
// Agent message with a URL — should get exactly one hyperlink per URL
// from the markdown renderer, not doubled by the plain-text scan.
let entries = vec![make_markdown_entry("Visit https://example.com for info.\n")];
let viewport = Rect::new(0, 0, 80, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let url_count = result
.link_overlay
.links()
.iter()
.filter(|l| &*l.url == "https://example.com")
.count();
assert_eq!(
url_count, 1,
"markdown block should not be scanned again by the plain-text scanner"
);
}
#[test]
fn collapsed_block_body_urls_not_visible() {
// URLs in the output body are not rendered when collapsed (only the
// header line is shown), so no link overlay entries should appear.
let entries = vec![ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo test",
"See https://example.com",
None::<String>,
))];
let mut entries = entries;
entries[0].display_mode = DisplayMode::Collapsed;
let viewport = Rect::new(0, 0, 80, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
assert!(
result.link_overlay.is_empty(),
"output-body URLs should not appear when block is collapsed"
);
}
#[test]
fn collapsed_block_header_file_path_is_scanned() {
// File paths in the command header line should be linkified even
// when the block is collapsed.
let mut entries = vec![ScrollbackEntry::new(RenderBlock::execute_with_output(
"cd /Users/foo/project && ls",
"file1\nfile2",
None::<String>,
))];
entries[0].display_mode = DisplayMode::Collapsed;
let viewport = Rect::new(0, 0, 80, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let urls: Vec<&str> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.collect();
assert!(
urls.iter()
.any(|u| u.starts_with("file:///Users/foo/project")),
"file path in collapsed header should be linkified, got: {urls:?}"
);
}
#[test]
fn group_header_entry_does_not_leak_hidden_line_links() {
let mut entries = vec![
ScrollbackEntry::new(RenderBlock::execute_with_output(
"cd /Users/foo/project && ls",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo hidden",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo visible",
"out",
None::<String>,
)),
];
for e in &mut entries {
e.display_mode = DisplayMode::Collapsed;
}
let layouts = vec![
EntryLayoutInfo {
height: 1,
gap_after: 0,
group_header_count: 1,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 0,
gap_after: 0,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 1,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
];
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let viewport = Rect::new(0, 0, 80, 10);
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let links: Vec<(u16, u16, u16, String)> = result
.link_overlay
.links()
.iter()
.map(|l| (l.screen_row, l.col_start, l.col_end, l.url.to_string()))
.collect();
assert!(
links
.iter()
.all(|(_, _, _, url)| !url.contains("/Users/foo/project")),
"path from the header-replaced entry's hidden line must not be linkified, got: {links:?}"
);
assert!(
links.iter().all(|(row, ..)| *row != 0),
"no link may land on the '◈ N more' header row, got: {links:?}"
);
}
#[test]
fn collapse_header_entry_does_not_leak_links_but_visible_group_entries_do() {
// Smallest shape the truncation fold can produce for an expanded
// group: 3 entries, header count = group_len - 1 = 2.
let mut entries = vec![
ScrollbackEntry::new(RenderBlock::execute_with_output(
"cd /Users/foo/hidden && ls",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"cat /Users/foo/visible/file.txt",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo plain",
"out",
None::<String>,
)),
];
for e in &mut entries {
e.display_mode = DisplayMode::Collapsed;
}
let layouts = vec![
EntryLayoutInfo {
height: 1,
gap_after: 0,
group_header_count: 2,
group_collapse_header: true,
verb_group_header: false,
},
EntryLayoutInfo {
height: 1,
gap_after: 0,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 1,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
];
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let viewport = Rect::new(0, 0, 80, 10);
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let links: Vec<(u16, String)> = result
.link_overlay
.links()
.iter()
.map(|l| (l.screen_row, l.url.to_string()))
.collect();
assert!(
links
.iter()
.all(|(_, url)| !url.contains("/Users/foo/hidden")),
"collapse-header entry's hidden line must not be linkified, got: {links:?}"
);
assert!(
links
.iter()
.any(|(row, url)| *row == 1 && url.contains("/Users/foo/visible/file.txt")),
"visible group entry below the collapse header must still be linkified, got: {links:?}"
);
}
#[test]
fn group_header_entry_contributes_no_selectable_lines() {
let mut entries = vec![
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo hidden-secret-command",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo also-hidden",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo visible-command",
"out",
None::<String>,
)),
];
for e in &mut entries {
e.display_mode = DisplayMode::Collapsed;
}
let layouts = vec![
EntryLayoutInfo {
height: 1,
gap_after: 0,
group_header_count: 1,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 0,
gap_after: 0,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 1,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
];
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let viewport = Rect::new(0, 0, 80, 10);
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let lines: Vec<(usize, u16, String)> = result
.selection_model
.ranges
.iter()
.flat_map(|r| r.lines.iter())
.map(|l| (l.entry_idx, l.screen_y, l.text.clone()))
.collect();
assert!(
lines
.iter()
.all(|(_, _, text)| !text.contains("hidden-secret-command")),
"the header-replaced entry's hidden line must not be selectable, got: {lines:?}"
);
assert!(
lines.iter().all(|(_, row, _)| *row != 0),
"no selectable line may land on the '◈ N more' header row, got: {lines:?}"
);
assert!(
lines.iter().any(|(idx, row, text)| *idx == 2
&& *row == 1
&& text.contains("echo visible-command")),
"visible entry below the header must still be selectable, got: {lines:?}"
);
}
/// The verb-group header's synthetic selectable line tracks the rendered
/// label geometry: both fold states shift the hitbox past the diamond
/// chrome onto the label glyphs, so highlight always matches the copied
/// text.
#[test]
fn verb_group_header_selection_geometry_tracks_chrome() {
crate::appearance::cache::set_show_thinking_blocks(false);
// Absolute path so the URL/path scanner linkifies member 0's row.
let mut entries = vec![
ScrollbackEntry::new(RenderBlock::read("/tmp/verbgeo/a1.rs", None)),
ScrollbackEntry::new(RenderBlock::read("a2.rs", None)),
];
for e in &mut entries {
e.display_mode = DisplayMode::Collapsed;
}
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let viewport = Rect::new(0, 0, 80, 10);
let layouts_for = |expanded: bool| {
vec![
EntryLayoutInfo {
// Expanded slot = header line + entry 0's own row.
height: if expanded { 2 } else { 1 },
gap_after: 0,
// Verb headers carry the run's tool-member count.
group_header_count: 2,
group_collapse_header: expanded,
verb_group_header: true,
},
EntryLayoutInfo {
height: if expanded { 1 } else { 0 },
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
]
};
let header_line = |expanded: bool| {
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts_for(expanded),
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
result
.selection_model
.ranges
.iter()
.flat_map(|r| r.lines.iter())
.find(|l| l.entry_idx == 0 && l.screen_y == 0)
.cloned()
.unwrap_or_else(|| panic!("verb header must contribute a selectable line"))
};
let collapsed = header_line(false);
assert_eq!(collapsed.text, "Read 2 files");
let expanded = header_line(true);
assert_eq!(expanded.text, "Read 2 files");
assert_eq!(
collapsed.selectable_cols, expanded.selectable_cols,
"hitbox always spans exactly the label glyphs"
);
// The expanded slot also exposes member 0's own content line at the
// row below the header, selectable like any other member row.
let member_line_at = |expanded: bool| {
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts_for(expanded),
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
result
.selection_model
.ranges
.iter()
.flat_map(|r| r.lines.iter())
.find(|l| l.entry_idx == 0 && l.screen_y == 1)
.cloned()
};
let member = member_line_at(true).expect("expanded slot maps member 0's line at row 1");
assert!(
member.text.contains("a1.rs"),
"member 0's own line must be selectable below the header, got {:?}",
member.text
);
assert!(
member_line_at(false).is_none(),
"collapsed slot must not map hidden member content"
);
// Collapsed Read headers show basename only (`a1.rs`), so the plain-
// text path scanner no longer sees the absolute `/tmp/verbgeo/…`
// string and will not emit a file link for it. Folded members still
// expose no links; expanded members remain selectable (asserted above).
let member_link_rows = |expanded: bool| {
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts_for(expanded),
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
result
.link_overlay
.links()
.iter()
.filter(|l| l.url.contains("a1.rs"))
.map(|l| l.screen_row)
.collect::<Vec<_>>()
};
// If a link is still produced (e.g. relative basename scanners), it
// must sit on the member content row under the verb header.
for row in member_link_rows(true) {
assert_eq!(
row, 1,
"member 0 path link must map to the row below the header"
);
}
assert!(
member_link_rows(false).is_empty(),
"folded member must expose no links"
);
// Both states wear the diamond chrome: the hitbox starts past it at
// the same x either way. The label begins at the content column
// (accent + left pad — NOT `chrome_width`, which also counts the
// right pad) plus the diamond prefix.
let expected_x = HorizontalLayout::ACCENT
+ appearance.scrollback.layout.block_pad_left
+ group_header_chrome_prefix_width();
assert_eq!(
collapsed.screen_x, expected_x,
"collapsed hitbox sits past accent chrome + diamond prefix"
);
assert_eq!(
expanded.screen_x, expected_x,
"expanded hitbox sits past accent chrome + diamond prefix"
);
}
/// The expanded slot's two rows are independent drag targets: the
/// synthetic header line keys its own reserved range, so a drag anchored
/// on either row paints and copies that row alone. Before the reserved
/// id, both rows shared (entry 0, range 0, block line 0) — `push_line`
/// merged them into one range and a drag on either selected both.
#[test]
fn verb_group_expanded_slot_header_and_member_select_independently() {
use crate::scrollback::text_selection::reconstruct_selection_text;
crate::appearance::cache::set_show_thinking_blocks(false);
let mut entries = vec![
ScrollbackEntry::new(RenderBlock::read("/tmp/verbind/b1.rs", None)),
ScrollbackEntry::new(RenderBlock::read("b2.rs", None)),
];
for e in &mut entries {
e.display_mode = DisplayMode::Collapsed;
}
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let viewport = Rect::new(0, 0, 80, 10);
// Expanded slot: header line + member 0's own row.
let layouts = vec![
EntryLayoutInfo {
height: 2,
gap_after: 0,
group_header_count: 2,
group_collapse_header: true,
verb_group_header: true,
},
EntryLayoutInfo {
height: 1,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
];
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
let model = &result.selection_model;
// The two rows resolve to two distinct ranges, one line each on
// their own screen rows.
let header = model.range(0, GROUP_HEADER_RANGE_ID).expect("header range");
assert_eq!(header.lines.len(), 1);
assert_eq!(header.lines[0].text, "Read 2 files");
assert_eq!(header.lines[0].screen_y, 0);
let member = model
.range(0, crate::scrollback::blocks::tool::TOOL_HEADER_RANGE)
.expect("member range");
assert_eq!(member.lines.len(), 1);
assert!(member.lines[0].text.contains("b1.rs"));
assert_eq!(member.lines[0].screen_y, 1);
// A same-row drag over each row reconstructs that row's text only.
let header_copy = reconstruct_selection_text(model, &full_row_drag(&header.lines[0]))
.expect("header copy");
assert_eq!(header_copy, "Read 2 files");
let member_copy = reconstruct_selection_text(model, &full_row_drag(&member.lines[0]))
.expect("member copy");
assert!(
member_copy.contains("b1.rs") && !member_copy.contains("Read 2 files"),
"member drag must not drag the header along: {member_copy:?}"
);
}
#[test]
fn group_header_entry_not_search_highlighted_from_hidden_text() {
let mut entries = vec![
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo hidden-secret-command",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo step",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo visible-command",
"out",
None::<String>,
)),
];
for e in &mut entries {
e.display_mode = DisplayMode::Collapsed;
}
let layouts = vec![
EntryLayoutInfo {
height: 1,
gap_after: 0,
group_header_count: 1,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 0,
gap_after: 0,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 1,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
];
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let viewport = Rect::new(0, 0, 80, 10);
let mut buf = Buffer::empty(viewport);
let re = regex::Regex::new("command").unwrap();
render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
Some(&re),
0,
0,
&[],
None,
None,
);
assert!(
reversed_cols_on_row(&buf, 0).is_empty(),
"hidden-line match must not highlight cells on the '◈ N more' header row"
);
assert!(
!reversed_cols_on_row(&buf, 1).is_empty(),
"match in the visible entry below the header must still highlight"
);
}
#[test]
fn group_header_media_entry_registers_no_media_placements() {
use crate::scrollback::blocks::tool::{OtherToolCallBlock, ToolCallBlock};
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let dir = tempfile::tempdir().unwrap();
let image_path = dir.path().join("hidden.png");
std::fs::write(&image_path, make_test_png(120, 120)).unwrap();
let mut entries = vec![
ScrollbackEntry::new(RenderBlock::ToolCall(ToolCallBlock::Other(
OtherToolCallBlock::new("media_tool", "saved image")
.with_media_ref(&image_path, false),
))),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo hidden",
"out",
None::<String>,
)),
ScrollbackEntry::new(RenderBlock::execute_with_output(
"echo visible",
"out",
None::<String>,
)),
];
for e in &mut entries {
e.display_mode = DisplayMode::Collapsed;
}
let layouts = vec![
EntryLayoutInfo {
height: 1,
gap_after: 0,
group_header_count: 1,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 0,
gap_after: 0,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
EntryLayoutInfo {
height: 1,
gap_after: 1,
group_header_count: 0,
group_collapse_header: false,
verb_group_header: false,
},
];
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let refs: Vec<&ScrollbackEntry> = entries.iter().collect();
let viewport = Rect::new(0, 0, 80, 10);
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
assert!(
result.inline_media.is_empty(),
"a header-replaced media entry must not register image rows or click \
targets over the following entries' rows, got: {:?}",
result.inline_media
);
}
#[test]
fn truncated_execute_block_detects_urls_in_head_and_tail() {
// Default truncation: first_lines=2, last_lines=3, threshold=5.
// Build output > 5 lines with URLs in both the head and tail sections.
let output = [
"https://head.example.com/first",
"line two",
"line three",
"line four",
"line five",
"line six",
"line seven",
"line eight",
"https://tail.example.com/last",
]
.join("\n");
let mut entries = vec![ScrollbackEntry::new(RenderBlock::execute_with_output(
"curl test",
&output,
None::<String>,
))];
entries[0].display_mode = DisplayMode::Truncated;
let viewport = Rect::new(0, 0, 80, 30);
let result = render_with_scratch(&entries, viewport, 0, None);
let urls: Vec<&str> = result
.link_overlay
.links()
.iter()
.map(|l| &*l.url)
.collect();
assert!(
urls.contains(&"https://head.example.com/first"),
"URL in head section should be detected, got: {urls:?}",
);
assert!(
urls.contains(&"https://tail.example.com/last"),
"URL in tail section should be detected, got: {urls:?}",
);
// The ellipsis separator line should not produce spurious links.
assert_eq!(
urls.len(),
2,
"only the two real URLs should be detected, got: {urls:?}",
);
}
/// Collapsed Edit header: after bullet prepend the path is span 2, and the
/// OSC8 overlay must cover path cols only (not the verb or bullet).
#[test]
fn tool_header_link_url_overlay_covers_path_after_bullet() {
use crate::appearance::ToolBullet;
use crate::scrollback::types::{BlockContext, selectable_cols};
use unicode_width::UnicodeWidthStr;
let abs = "/Users/me/project/src/foo.rs";
let cwd = std::path::PathBuf::from("/Users/me/project");
let mut entry = ScrollbackEntry::new(RenderBlock::edit(abs, None));
entry.display_mode = DisplayMode::Collapsed;
let mut appearance = AppearanceConfig::default();
appearance.scrollback.blocks.tool.bullet = ToolBullet::Diamond;
let ctx = BlockContext {
mode: DisplayMode::Collapsed,
is_running: false,
width: 80,
raw: false,
max_lines: None,
appearance: appearance.clone(),
is_selected: false,
cwd: Some(cwd.clone()),
};
let painted = entry.block.output(&ctx);
let header = &painted.lines[0];
assert!(
header.content.spans.len() >= 3,
"expected [bullet, Edit , path], got {} spans",
header.content.spans.len()
);
let path_span = header.content.spans[2].content.as_ref();
assert_eq!(path_span, "foo.rs");
let url = header.link_url.as_ref().expect("link_url on header");
assert!(url.starts_with("file://"), "got {url}");
assert!(url.contains("foo.rs"), "got {url}");
let cols = selectable_cols(&header.content, &header.selectable)
.expect("path span should be selectable");
let bullet_w = header.content.spans[0].content.width() as u16;
let verb_w = header.content.spans[1].content.width() as u16;
let path_w = path_span.width() as u16;
assert_eq!(
cols,
(bullet_w + verb_w)..(bullet_w + verb_w + path_w),
"selectable cols must be path-only after bullet shift"
);
let viewport = Rect::new(0, 0, 80, 10);
let theme = Theme::current();
let layouts = compute_layouts(std::slice::from_ref(&entry), viewport.width, &appearance);
let refs: Vec<&ScrollbackEntry> = vec![&entry];
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
Some(cwd.as_path()),
);
let file_links: Vec<_> = result
.link_overlay
.links()
.iter()
.filter(|l| l.url.contains("foo.rs") && l.url.starts_with("file://"))
.collect();
assert_eq!(
file_links.len(),
1,
"expected one file:// overlay for the tool path, got {:?}",
result.link_overlay.links()
);
let hlayout = HorizontalLayout::new(viewport, &appearance.scrollback.layout);
assert_eq!(
file_links[0].col_start,
hlayout.content.x.saturating_add(cols.start),
"overlay must start on path, not bullet/verb"
);
assert_eq!(
file_links[0].col_end,
hlayout.content.x.saturating_add(cols.end),
"overlay must end at path end"
);
}
#[test]
fn long_read_header_link_is_clipped_to_offset_content_area() {
let path = "/outside/a/very/long/path/that/is/clipped/main.rs";
let mut entry = ScrollbackEntry::new(RenderBlock::read(path, None));
entry.display_mode = DisplayMode::Expanded;
let viewport = Rect::new(11, 0, 24, 5);
let result = render_with_scratch(std::slice::from_ref(&entry), viewport, 0, None);
let link = result
.link_overlay
.links()
.iter()
.find(|link| link.url.contains("main.rs"))
.expect("read header file link");
let content =
HorizontalLayout::new(viewport, &AppearanceConfig::default().scrollback.layout).content;
assert!(link.col_start >= content.left());
assert_eq!(link.col_end, content.right());
}
#[test]
fn explicit_tool_link_clips_before_u16_conversion() {
let path = format!("/outside/{}.rs", "x".repeat(70_000));
let mut entry = ScrollbackEntry::new(RenderBlock::read(path, None));
entry.display_mode = DisplayMode::Expanded;
let viewport = Rect::new(9, 0, 40, 5);
let result = render_with_scratch(std::slice::from_ref(&entry), viewport, 0, None);
let link = result
.link_overlay
.links()
.iter()
.find(|link| link.url.ends_with(".rs"))
.expect("long Read header file link");
let content =
HorizontalLayout::new(viewport, &AppearanceConfig::default().scrollback.layout).content;
assert!(link.col_start >= content.left());
assert_eq!(link.col_end, content.right());
}
#[test]
fn edit_boundary_sidecar_stays_aligned_when_prefix_row_is_clipped() {
let mut entry = ScrollbackEntry::new(RenderBlock::edit(" foo.rs", None));
entry.display_mode = DisplayMode::Expanded;
let viewport = Rect::new(0, 0, 8, 3);
let rendered = render_with_selection_boundaries(std::slice::from_ref(&entry), viewport, 1);
let result = &rendered.result;
let (line, boundary) = result
.selection_model
.ranges
.iter()
.flat_map(|range| &range.lines)
.find_map(|line| {
let hit = crate::scrollback::text_selection::RangeHit {
entry_idx: line.entry_idx,
range_id: line.range_id,
block_line_idx: line.block_line_idx,
col_within_range: 0,
};
rendered
.selection_boundaries
.boundary_for_hit(&hit)
.map(|boundary| (line, boundary))
})
.expect("visible path fragment keeps its cached boundary");
let copied = boundary.apply(line.text.clone(), true, false);
assert!(copied.starts_with(" "), "copied={copied:?}");
}
#[test]
fn ordinary_render_has_no_resolved_selection_boundaries() {
let entries = vec![make_markdown_entry("ordinary selectable text")];
let rendered = render_with_selection_boundaries(&entries, Rect::new(0, 0, 40, 5), 0);
assert!(!rendered.result.selection_model.ranges.is_empty());
assert!(rendered.selection_boundaries.is_empty());
}
#[test]
fn read_header_link_is_dropped_when_path_has_no_visible_columns() {
let mut entry = ScrollbackEntry::new(RenderBlock::read("/outside/long-file-name.rs", None));
entry.display_mode = DisplayMode::Expanded;
let viewport = Rect::new(7, 0, 5, 3);
let result = render_with_scratch(std::slice::from_ref(&entry), viewport, 0, None);
assert!(
result
.link_overlay
.links()
.iter()
.all(|link| !link.url.contains("/outside/long-file-name.rs")),
"off-row path cells must not be clickable: {:?}",
result.link_overlay.links()
);
}
/// Collect all `OverlayLink`s for `url` grouped by `OverlayLink::id`,
/// returning the largest id-group (the wrapped URL's fragment set).
///
/// In pretty mode `[text](url)` produces two HyperlinkTargets that BOTH
/// reference `url` but have distinct ids: one for the link text
/// (lower id, parser-produced) and one for the `(url)` suffix
/// (higher id, url_scan-produced). The wrapped URL fragments share
/// an id; for URL-wrap tests the url_scan group is the one that
/// spans multiple rows. Panics with a verbose diagnostic if no
/// group has at least one entry.
///
/// Tie behaviour: `Iterator::max_by_key` returns the LAST equally-
/// maximum element, and `BTreeMap::into_iter()` yields entries in
/// ascending key order, so if both groups have the same number of
/// fragments the higher-id group wins — which is the
/// url_scan-produced URL-suffix group, the right pick for these
/// tests. The helper is unambiguous for all current callers; a
/// future caller with a different `[parser, url_scan]` shape may
/// want to filter `result.link_overlay.links()` directly.
fn url_overlay_group<'a>(result: &'a ScrollRenderResult, url: &str) -> Vec<&'a OverlayLink> {
let mut by_id: std::collections::BTreeMap<u32, Vec<&OverlayLink>> =
std::collections::BTreeMap::new();
for l in result.link_overlay.links() {
if &*l.url == url
&& let Some(id) = l.id
{
by_id.entry(id).or_default().push(l);
}
}
if by_id.is_empty() {
panic!(
"no OverlayLinks matched url={url:?}; all overlays: {:?}",
result
.link_overlay
.links()
.iter()
.map(|l| (l.screen_row, l.col_start, l.col_end, &*l.url, l.id))
.collect::<Vec<_>>(),
);
}
let (_id, group) = by_id
.into_iter()
.max_by_key(|(_, v)| v.len())
.expect("checked non-empty above");
group
}
/// Assert that `fragments` are on strictly-increasing CONSECUTIVE
/// screen rows (rows must differ by exactly 1). Catches partial
/// regressions where the middle of a wrapped URL is silently skipped.
fn assert_consecutive_rows(fragments: &[&OverlayLink]) {
for w in fragments.windows(2) {
assert_eq!(
w[1].screen_row,
w[0].screen_row + 1,
"wrapped URL fragments must be on consecutive screen rows: {:?}",
fragments
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
}
}
/// Regression test for the bug where a markdown link `[text](url)`
/// rendered in pretty mode loses the OSC 8 wrapper (and the terminal's
/// auto-styling) on every wrapped row of the URL portion EXCEPT the
/// first. Every wrapped row covering URL bytes must receive its own
/// `OverlayLink` so the entire URL is clickable and styled.
#[test]
fn overlay_pretty_link_url_wraps_across_rows() {
// Long URL with hyphens that force HyphenSplitter to wrap mid-URL.
// Synthetic long hyphenated URL (length/shape match wrap regression needs).
let url = "https://example.com/d/seg-00-wrap-seg-01-wrap-seg-02-wrap-seg-03-wrap-seg-04-wrap-seg-05-wrap-seg-06-wrap-seg-07-wrap-seg-08-wrap-seg-09-wrap-seg-10";
let markdown = format!("[Example Dashboard -- Long Title For Wrap]({url})\n");
let entries = vec![make_markdown_entry(&markdown)];
// Picked so the rendered link `text (url)` wraps multiple times.
let viewport = Rect::new(0, 0, 50, 20);
let result = render_with_scratch(&entries, viewport, 0, None);
// The pre-wrap line is ~185 cells wide and per-block content area
// is ~35 cells (viewport - timestamp reservation - layout chrome).
// The link text takes row 0 (33 cells), then the URL portion
// wraps onto exactly 5 continuation rows. This is a plain
// paragraph (no `subsequent_indent`), so the combined fragment
// widths must equal the URL's display width — a strong
// invariant that would fail under any row drop or off-by-N
// column-tracking regression.
let group = url_overlay_group(&result, url);
assert_eq!(
group.len(),
5,
"expected the URL to wrap onto exactly 5 rows; got {} fragments: {:?}",
group.len(),
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
assert_consecutive_rows(&group);
let combined_width: u32 = group.iter().map(|o| (o.col_end - o.col_start) as u32).sum();
assert_eq!(
combined_width as usize,
url.len(),
"combined fragment widths must equal URL display width; got fragments: {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
}
/// Short-ish URL inside prose, wrapping onto multiple rows (no
/// blockquote/list indent involvement). For paragraph wrapping
/// `map_hyperlinks_to_overlay` produces OverlayLinks whose combined
/// width exactly equals the URL's display width — the strongest
/// invariant we can pin for the non-indented case.
#[test]
fn overlay_pretty_link_url_wraps_multi_row_paragraph() {
let url = "https://example.com/some/long/path/that/will/wrap/once-and-twice";
let markdown = format!("See [docs]({url}) for more.\n");
let entries = vec![make_markdown_entry(&markdown)];
let viewport = Rect::new(0, 0, 50, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let group = url_overlay_group(&result, url);
assert!(
group.len() >= 2,
"URL should wrap onto multiple rows; got {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
assert_consecutive_rows(&group);
// The combined width of all fragments must equal the URL's
// display width (URLs are pure ASCII → display width == byte len).
// Any silently-dropped middle row would make this sum unequal.
let combined_width: u32 = group.iter().map(|o| (o.col_end - o.col_start) as u32).sum();
assert_eq!(
combined_width as usize,
url.len(),
"combined fragment widths must equal URL display width; got fragments: {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
}
/// CJK link text: column tracking must be display-width aware. If
/// the bug had used byte length for `日` (3 bytes, but 2 display
/// cells), the column accounting would be off by N cells per CJK
/// character and the URL fragments wouldn't sum to the URL's
/// display width. We assert that combined fragment widths equal
/// the URL's display width — the strongest cell-width invariant we
/// can pin without re-deriving the entire wrap layout.
#[test]
fn overlay_pretty_link_url_with_cjk_text() {
use unicode_width::UnicodeWidthStr;
let url = "https://example.com/very-long-path-with-many-hyphens/foo/bar/baz/qux";
let markdown = format!("[日本語のリンク テキスト]({url})\n");
let entries = vec![make_markdown_entry(&markdown)];
let viewport = Rect::new(0, 0, 40, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let group = url_overlay_group(&result, url);
assert!(
group.len() >= 2,
"CJK link text with wrapping URL should produce at least 2 overlay rows",
);
assert_consecutive_rows(&group);
// Combined fragment widths must equal the URL's display width.
// For pure-ASCII URLs that's `url.len()`. A byte-vs-cell
// regression in CJK column tracking would propagate as a wrong
// pre-wrap column for the URL HyperlinkTarget, which in turn
// would clip or shift one of the fragments — making the sum
// unequal.
let combined_width: u32 = group.iter().map(|o| (o.col_end - o.col_start) as u32).sum();
assert_eq!(
combined_width as usize,
UnicodeWidthStr::width(url),
"combined fragment widths must equal URL display width",
);
}
/// Long URL inside a blockquote. The OverlayLink for the URL must
/// cover continuation rows so OSC 8 is present on every wrapped row.
///
/// KNOWN-BUG: the current
/// `map_hyperlinks_to_overlay` accumulates `cumulative_col` using
/// `line.content.width()`, which INCLUDES the `│ ` indent injected
/// by `word_wrap_line_with_joiners` on continuation rows. Two
/// consequences for blockquote/list URL wraps:
/// (a) Cosmetic: the OverlayLink on continuation rows starts at
/// `content_x` (covering the `│ ` indent), so the indent
/// characters are OSC 8 wrapped and inherit the terminal's
/// auto-styling (underline/colour).
/// (b) Functional: because `cumulative_col` over-counts by
/// `indent_width` cells per continuation row, the last
/// `indent_width` cells of the URL on each continuation row
/// are NOT covered by an OverlayLink — those trailing chars
/// are not clickable. With N continuation rows the
/// unclickable tail accumulates to `N * indent_width` cells.
/// The original PR's invariant (OSC 8 present on every wrapped row
/// of the URL) IS satisfied. This test pins that invariant; once
/// the bug is fixed (needs `BlockLine` to carry `subsequent_indent`
/// width), tighten the assertions to `col_start == content_x +
/// indent_width` on continuation rows and pin
/// `sum(fragment_widths) == url.display_width()`.
#[test]
fn overlay_pretty_link_url_in_blockquote_wraps_correctly() {
let url = "https://example.com/blockquote/path/with/many/hyphens-and-segments-here";
let markdown = format!("> See [docs]({url}) for more.\n");
let entries = vec![make_markdown_entry(&markdown)];
let viewport = Rect::new(0, 0, 50, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let content_x = result.selection_model.content_area.x;
let group = url_overlay_group(&result, url);
assert!(
group.len() >= 2,
"blockquote URL should wrap; got fragments: {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
assert_consecutive_rows(&group);
// All fragments must be inside the viewport content area.
for frag in &group {
assert!(
frag.col_start >= content_x,
"OverlayLink must start at or after content_x",
);
assert!(
frag.col_end <= content_x + viewport.width,
"OverlayLink must not exceed the viewport content width",
);
}
}
/// Long URL inside a list item. Same OSC-coverage invariant as the
/// blockquote test above; see that test for the rationale and the
/// description of the related indent-inclusion
/// bug (both the cosmetic indent-over-styling AND the functional
/// "last `indent_width` cells of URL not clickable on continuation
/// rows" symptoms apply here too).
#[test]
fn overlay_pretty_link_url_in_list_wraps_correctly() {
let url = "https://example.com/list/item/path/with/many/hyphens-and-segments-here";
let markdown = format!("- See [docs]({url}) for more.\n");
let entries = vec![make_markdown_entry(&markdown)];
let viewport = Rect::new(0, 0, 50, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let content_x = result.selection_model.content_area.x;
let group = url_overlay_group(&result, url);
assert!(
group.len() >= 2,
"list URL should wrap; got fragments: {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
assert_consecutive_rows(&group);
for frag in &group {
assert!(
frag.col_start >= content_x,
"OverlayLink must start at or after content_x",
);
assert!(
frag.col_end <= content_x + viewport.width,
"OverlayLink must not exceed the viewport content width",
);
}
}
/// Width changes trigger `set_max_table_width` resets inside
/// `MarkdownContent::ensure_wrapped`. URL hyperlinks must survive
/// every step of a narrow→narrower→wider→narrow sequence (production
/// terminal pane drags fire many such transitions).
#[test]
fn overlay_url_hyperlinks_survive_width_change() {
let url = "https://example.com/long/url/path/with/many/segments-and-hyphens";
let markdown = format!("[link]({url})\n");
let mut entries = vec![make_markdown_entry(&markdown)];
if let RenderBlock::AgentMessage(b) = &mut entries[0].block {
b.finish();
}
// Exercise multiple width transitions: a wide viewport where
// the URL fits on one row, a narrow viewport where it must
// wrap onto multiple rows, and back-and-forth between them.
// The URL must remain present at every step.
//
// The "wide" threshold (120) is chosen so the per-block content
// area exceeds the URL's display width + leading "link (" prefix
// even after the 10-cell timestamp reservation and ~4 cells of
// layout chrome. The "narrow" widths (30, 50) force wrapping.
for width in [120u16, 50, 30, 50, 120, 30] {
let result = render_with_scratch(&entries, Rect::new(0, 0, width, 10), 0, None);
let group = url_overlay_group(&result, url);
if width >= 120 {
// Wide enough: URL fits on a single row.
assert_eq!(
group.len(),
1,
"URL at width={width} should fit on one row; got: {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
assert_eq!(
(group[0].col_end - group[0].col_start) as usize,
url.len(),
"single-row URL fragment width must equal URL display width",
);
} else {
// Narrow: URL must wrap onto consecutive rows, with
// combined fragment widths summing to the URL width.
assert!(
group.len() >= 2,
"URL at width={width} should wrap onto multiple rows; got: {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
assert_consecutive_rows(&group);
let combined_width: u32 =
group.iter().map(|o| (o.col_end - o.col_start) as u32).sum();
assert_eq!(
combined_width as usize,
url.len(),
"URL at width={width}: combined fragment widths must equal \
URL display width; got fragments: {:?}",
group
.iter()
.map(|o| (o.screen_row, o.col_start, o.col_end))
.collect::<Vec<_>>(),
);
}
}
}
/// Short URL that fits on a single row produces exactly one
/// `OverlayLink`, sized to the URL's display width.
#[test]
fn overlay_pretty_link_url_no_wrap_single_row() {
let url = "https://a.example/x";
let markdown = format!("See [docs]({url}) here.\n");
let entries = vec![make_markdown_entry(&markdown)];
let viewport = Rect::new(0, 0, 80, 10);
let result = render_with_scratch(&entries, viewport, 0, None);
let group = url_overlay_group(&result, url);
assert_eq!(
group.len(),
1,
"short URL must produce exactly one OverlayLink fragment",
);
assert_eq!(
(group[0].col_end - group[0].col_start) as usize,
url.len(),
"single-row URL fragment width must equal URL display width",
);
}
/// Two markdown links on the same pre-wrap line, both with URLs long
/// enough to wrap their `(url)` suffixes; each URL must produce its
/// own id-group and the id-groups must be distinct (no merging).
#[test]
fn overlay_pretty_two_wrapping_links_distinct_ids() {
let url_a = "https://aaa.example/with-many-segments/and-more-hyphens/foo/bar/baz";
let url_b = "https://bbb.example/another-set-of-segments/and-more-hyphens/qux/quux";
let markdown = format!("See [link-a]({url_a}) and [link-b]({url_b}) end.\n");
let entries = vec![make_markdown_entry(&markdown)];
let viewport = Rect::new(0, 0, 50, 30);
let result = render_with_scratch(&entries, viewport, 0, None);
let group_a = url_overlay_group(&result, url_a);
let group_b = url_overlay_group(&result, url_b);
let id_a = group_a[0].id.expect("URL fragment must have id");
let id_b = group_b[0].id.expect("URL fragment must have id");
assert_ne!(
id_a, id_b,
"two distinct URLs must produce distinct OSC 8 ids",
);
assert!(group_a.len() >= 2, "URL A should wrap onto multiple rows");
assert!(group_b.len() >= 2, "URL B should wrap onto multiple rows");
// Wrapped fragments must land on strictly consecutive rows
// (no silently-dropped middle row).
assert_consecutive_rows(&group_a);
assert_consecutive_rows(&group_b);
// The full set of OverlayLink ids referencing either URL must
// have at least 4 distinct entries: parser link-text id for "link-a",
// url_scan id for `(url_a)`, parser link-text id for "link-b", and
// url_scan id for `(url_b)`. A regression where any two of these
// collide (e.g. parser hyperlink IDs not advanced past url_scan IDs
// across paragraphs) would silently merge OSC 8 hyperlinks.
let ids: std::collections::HashSet<u32> = result
.link_overlay
.links()
.iter()
.filter(|l| &*l.url == url_a || &*l.url == url_b)
.filter_map(|l| l.id)
.collect();
assert!(
ids.len() >= 4,
"expected at least 4 distinct OSC 8 ids across the two links' \
parser + url_scan groups; got {} distinct ids: {:?}",
ids.len(),
ids,
);
}
/// A detected diagram exposes one affordance-row placement, anchored on a
/// real screen row at the content-area x, carrying the diagram source — and
/// never an inline image (no `inline_media` placement). Rendering is lazy, so
/// the placement holds no path/state.
#[test]
fn diagram_emits_affordance_placement_not_inline_image() {
crate::appearance::cache::set_render_mermaid(crate::appearance::RenderMermaid::On);
let entry = make_markdown_entry("intro\n\n```mermaid\nA-->B\n```\n\nbye\n");
let viewport = Rect::new(0, 0, 80, 60);
let theme = Theme::current();
let appearance = AppearanceConfig::default();
let layouts = compute_layouts(std::slice::from_ref(&entry), viewport.width, &appearance);
let refs: Vec<&ScrollbackEntry> = vec![&entry];
let mut buf = Buffer::empty(viewport);
let result = render_scrolled_entries_with_scratch(
&mut buf,
viewport,
&refs,
0,
None,
&theme,
&appearance,
&layouts,
0,
None,
None,
None,
0,
0,
&[],
None,
None,
);
assert!(
result.inline_media.is_empty(),
"a diagram is never an inline image",
);
assert_eq!(
result.diagram_affordances.len(),
1,
"a ready diagram emits one affordance placement",
);
let aff = &result.diagram_affordances[0];
assert_eq!(aff.source, "A-->B\n");
// Exact placement geometry: one row tall, anchored at the content-area x,
// a non-empty width that stays within the content column band.
let hlayout = HorizontalLayout::new(viewport, &appearance.scrollback.layout);
assert_eq!(aff.screen_rect.height, 1, "the affordance row is one row");
assert_eq!(
aff.screen_rect.x, hlayout.content.x,
"anchored at the content-area x",
);
assert!(aff.screen_rect.width > 0, "non-degenerate width");
assert!(
aff.screen_rect.x + aff.screen_rect.width <= hlayout.content.x + hlayout.content.width,
"row stays within the content column band",
);
assert!(
aff.screen_rect.y >= viewport.y && aff.screen_rect.y < viewport.y + viewport.height,
"row is on-screen",
);
// The reserved affordance row renders blank here (the draw loop paints the
// buttons), and the diagram source sits on a row above it.
assert!(
buffer_row_text(&buf, aff.screen_rect.y).trim().is_empty(),
"the placement points at the reserved blank row",
);
assert!(
(viewport.y..aff.screen_rect.y).any(|y| buffer_row_text(&buf, y).contains("A-->B")),
"the diagram source renders above the affordance row",
);
}
fn make_test_png(width: u32, height: u32) -> Vec<u8> {
use image::{ImageBuffer, Rgba};
let img: ImageBuffer<Rgba<u8>, Vec<u8>> =
ImageBuffer::from_pixel(width, height, Rgba([128, 64, 32, 255]));
let mut buf = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut buf), image::ImageFormat::Png)
.unwrap();
buf
}
/// A tool-media overlay/image placement exposes the click-to-copy filepath as
/// its `filepath_screen_rect`, anchored on the block's second output row (the
/// filepath line) and sharing the image's content-column origin. This is the
/// assertion that the removed `has_filepath_line` flag previously carried.
#[test]
fn tool_media_overlay_exposes_filepath_click_rect() {
use crate::scrollback::blocks::tool::{OtherToolCallBlock, ToolCallBlock};
use crate::terminal::image::{GraphicsProtocol, set_protocol_for_test};
// Kitty overlay active → the block hosts its image overlay (no text
// `[Open]` line), so the sole placement is the overlay one whose second
// output line is the click-to-copy filepath.
let _guard = set_protocol_for_test(GraphicsProtocol::Kitty);
let dir = tempfile::tempdir().unwrap();
let image_path = dir.path().join("referenced.png");
std::fs::write(&image_path, make_test_png(120, 120)).unwrap();
let entry = ScrollbackEntry::new(RenderBlock::ToolCall(ToolCallBlock::Other(
OtherToolCallBlock::new("media_tool", "saved image").with_media_ref(&image_path, false),
)));
let viewport = Rect::new(0, 0, 80, 30);
let result = render_with_scratch(std::slice::from_ref(&entry), viewport, 0, None);
// Exactly the overlay/image placement (button row reserved); no separate
// text-`[Open]` placement when the overlay hosts the buttons.
assert_eq!(result.inline_media.len(), 1, "one overlay placement");
let media = &result.inline_media[0];
assert!(media.has_button_row, "overlay/image tool-media placement");
// The tool block has no vpad, so its second output line (the filepath) is
// screen row 1: a one-row click-to-copy target at the image's x.
let rect = media
.filepath_screen_rect
.expect("tool media exposes the click-to-copy filepath rect");
assert_eq!(
rect.y, 1,
"filepath rect on the block's second (filepath) line"
);
assert_eq!(rect.height, 1);
assert!(rect.width > 0, "non-degenerate width");
assert_eq!(
rect.x, media.screen_rect.x,
"shares the image's content-column x"
);
assert!(
media.screen_rect.y > rect.y,
"the image sits below its filepath line",
);
}
}