//! Block viewer — fullscreen content viewer for scrollback blocks. //! //! Opens via Ctrl-F on a selected block, replaces the scrollback area. //! Provides ListPane-based navigation, search, visual-select, and copy. //! //! Supports thinking/agent message blocks (markdown content). //! Execute and edit viewers will be added in later phases. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEventKind}; use kigi_workspace::permission::mcp_titleize_segment; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::StatefulWidget; use crate::clipboard::SystemClipboard; use crate::render::scrollbar::SCROLLBAR_TOTAL_COLS; use crate::scrollback::block::{BlockContent, RenderBlock}; use crate::scrollback::blocks::ToolCallBlock; use crate::scrollback::entry::{EntryId, ScrollbackEntry}; use crate::scrollback::types::{BlockContext, DisplayMode}; use crate::theme::{Theme, ThemeKind}; use crate::views::list_pane::{ ListItem, ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode, }; use crate::views::modal_window::ModalWindowState; use crate::views::shortcuts_bar::HintItem; // --------------------------------------------------------------------------- // ContentLine — generic ListItem for the viewer // --------------------------------------------------------------------------- /// A single line of content displayed in the block viewer's ListPane. #[derive(Clone)] pub struct ContentLine { /// Styled content to display. content: Line<'static>, /// Plain text for search matching. plain_text: String, /// Stable identity (pre-wrap line index). id: u64, /// Optional full-width background color (e.g., code block bg). bg: Option, } impl ContentLine { /// Build content lines from pre-wrap rendered markdown lines. pub fn from_lines(lines: &[Line<'static>]) -> Vec { lines .iter() .enumerate() .map(|(i, line)| { let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); Self { content: line.clone(), plain_text: plain, id: i as u64, bg: line.style.bg, } }) .collect() } } impl ListItem for ContentLine { fn content(&self) -> &Line<'_> { &self.content } fn stable_id(&self) -> u64 { self.id } fn search_text(&self) -> &str { &self.plain_text } fn background(&self) -> Option { self.bg } } // --------------------------------------------------------------------------- // DiffLineMeta — per-item diff metadata for edit viewer patch copy // --------------------------------------------------------------------------- /// Metadata for a single diff line, stored parallel to `items` in the edit viewer. pub struct DiffLineMeta { pub tag: similar::ChangeTag, pub text: String, pub lo: usize, pub ln: usize, } // --------------------------------------------------------------------------- // BlockViewerPane // --------------------------------------------------------------------------- /// What kind of block content the viewer is showing. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ViewerKind { /// Thinking or AgentMessage (markdown content). Markdown, /// Execute tool call (stdout). Execute, /// Edit tool call (diff). Edit, /// Background task (stdout from central store). BgTask, /// Web fetch tool call (fetched content). WebFetch, /// Web search tool call (search results + citations). WebSearch, /// Integration tool discovery (search_tool). IntegrationSearch, /// Integration tool dispatch (use_tool). UseTool, /// Read file tool call (file content). Read, Grep, /// Plain text content (e.g., catalog entry). PlainText, } /// Fullscreen block content viewer. /// /// Replaces the scrollback area when open. Owns a `ListPaneState` for /// navigation, search, and visual-select. Reads block content from the /// scrollback via `entry_id`. pub struct BlockViewerPane { /// Which scrollback entry we are viewing. pub entry_id: EntryId, /// What kind of content. pub kind: ViewerKind, /// ListPane state (scroll, selection, search, follow). pub list_state: ListPaneState, /// Visual style for the ListPane. list_style: ListPaneStyle, /// Content items for the ListPane. items: Vec, /// Cached content area from last render (for mouse hit-testing). last_content_area: Rect, /// Set by handle_key when 'r' is pressed. Caller should toggle raw mode /// on the entry and call rebuild_items(). pub raw_toggle_pending: bool, /// Set by handle_key when 'Y' is pressed. Caller should copy the command. pub copy_meta_pending: bool, /// Set by handle_key when 'y' is pressed on edit blocks. Caller should copy patch. pub copy_content_pending: bool, /// Per-item diff metadata for edit viewer (parallel to `items`). /// `None` entries are separator lines between hunks. diff_meta: Vec>, /// Last observed content generation (for streaming change detection). last_generation: u64, /// Whether the block was running when the viewer last checked. /// Used to detect the running→finished transition (disable follow once). was_running: bool, /// Task ID for BgTask viewers (for looking up stdout in central store). pub bg_task_id: Option, /// Last theme kind seen — used to detect theme switches and restyle. last_theme: ThemeKind, /// Modal chrome state (close button hover, popup area, etc.). pub modal: ModalWindowState, /// Cached prepend (preamble) ContentLines from the last render. Combined /// with `items` to produce the unified vec the ListPane sees. Needed so /// scroll / mouse / key handlers index into the same item space the /// layout was prepared against (`prepared_items_count`). prepend_items: Vec, /// Text-level drag selection (character-precise, like the scrollback /// pane). Anchor and head are STABLE references to characters in the /// unified items list — they survive scrolling so copy includes /// off-screen content. Active between `Down(Left)` inside the content /// area and the matching `Up(Left)`. Stays visible after release until /// the next click. pub text_drag: Option, /// Text copied on the last drag release. The `Up(Left)` handler /// extracts the text immediately (same as scrollback /// `finish_text_drag`); the caller drains this with `.take()` and /// copies it to the clipboard. pub drag_copy_text: Option, /// Cached unified items vec (prepend + body). Rebuilt by /// `rebuild_unified_cache` to avoid re-cloning on every handler and /// render call within the same frame. Callers that hold `&mut self` /// call `rebuild_unified_cache()` first, then reference /// `self.cached_unified` via a disjoint field borrow. cached_unified: Vec, } /// One end of a character-level text selection. /// /// References a position in the unified items list (preamble + body) by /// the stable `item_idx` and a display-column offset within that item's /// logical line text. Stable across scroll: `item_idx` doesn't change /// when the viewport moves. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct TextEndpoint { /// Index into the unified items vec (`prepend_items` then `items`). pub item_idx: usize, /// Display-column offset within `items[item_idx]`'s logical line text. /// Zero-based; `0` means "before the first character". pub col: u16, } /// Character-precise text selection within the modal content area. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct TextDrag { pub anchor: TextEndpoint, pub head: TextEndpoint, /// `true` while the mouse button is still down. Once released, the /// selection stays visible (for visual feedback) but isn't extended /// further. pub active: bool, } impl TextDrag { /// Endpoints in reading order (`start <= end`). pub fn ordered(&self) -> (TextEndpoint, TextEndpoint) { if self.anchor <= self.head { (self.anchor, self.head) } else { (self.head, self.anchor) } } /// Whether the selection covers at least one character. pub fn is_non_empty(&self) -> bool { let (s, e) = self.ordered(); s != e } } impl BlockViewerPane { /// Create a viewer for a markdown block (thinking or agent message). pub fn for_markdown(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let lines = Self::extract_markdown_lines(&entry.block)?; let generation = Self::extract_generation(&entry.block).unwrap_or(0); let config = ListPaneConfig { follow_enabled: entry.is_running, wrap_toggle_enabled: true, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: true, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::Wrap, entry.is_running, config); list_state.set_clipboard_provider(Box::new(SystemClipboard)); let items = ContentLine::from_lines(&lines); Some(Self { entry_id, kind: ViewerKind::Markdown, list_state, list_style: ListPaneStyle { uniform_visual_bg: true, ..ListPaneStyle::default() }, items, last_content_area: Rect::default(), raw_toggle_pending: false, copy_meta_pending: false, copy_content_pending: false, diff_meta: Vec::new(), last_generation: generation, was_running: entry.is_running, bg_task_id: None, last_theme: Theme::current_kind(), modal: ModalWindowState::new(), prepend_items: Vec::new(), text_drag: None, drag_copy_text: None, cached_unified: Vec::new(), }) } /// Create a viewer for an execute block (stdout output). pub fn for_execute(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) = &entry.block else { return None; }; let config = ListPaneConfig { follow_enabled: entry.is_running, wrap_toggle_enabled: true, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: true, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::Wrap, entry.is_running, config); list_state.set_clipboard_provider(Box::new(SystemClipboard)); let theme = Theme::current(); let items = Self::build_execute_items(exec.output.as_deref(), &theme); let last_output_len = exec.output.as_ref().map_or(0, |o| o.len()); // Dark background style for terminal output let list_style = ListPaneStyle { uniform_visual_bg: true, ..ListPaneStyle::default() }; Some(Self { entry_id, kind: ViewerKind::Execute, list_state, list_style, items, last_content_area: Rect::default(), raw_toggle_pending: false, copy_meta_pending: false, copy_content_pending: false, diff_meta: Vec::new(), last_generation: last_output_len as u64, // reuse generation field for output length was_running: entry.is_running, bg_task_id: None, last_theme: Theme::current_kind(), modal: ModalWindowState::new(), prepend_items: Vec::new(), text_drag: None, drag_copy_text: None, cached_unified: Vec::new(), }) } /// Create a viewer for static content lines (shared by web_fetch and web_search). fn for_static_content(entry_id: EntryId, kind: ViewerKind, lines: Vec>) -> Self { let config = ListPaneConfig { follow_enabled: false, wrap_toggle_enabled: true, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: true, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::Wrap, false, config); list_state.set_clipboard_provider(Box::new(SystemClipboard)); let items: Vec = lines .into_iter() .enumerate() .map(|(i, line)| { let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); ContentLine { content: line, plain_text: plain, id: i as u64, bg: None, } }) .collect(); Self { entry_id, kind, list_state, list_style: ListPaneStyle::default(), items, last_content_area: Rect::default(), raw_toggle_pending: false, copy_meta_pending: false, copy_content_pending: false, diff_meta: Vec::new(), last_generation: 0, was_running: false, bg_task_id: None, last_theme: Theme::current_kind(), modal: ModalWindowState::new(), prepend_items: Vec::new(), text_drag: None, drag_copy_text: None, cached_unified: Vec::new(), } } /// Create a viewer for a web fetch block (fetched content). pub fn for_web_fetch(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::WebFetch(fetch)) = &entry.block else { return None; }; let theme = Theme::current(); let content = fetch.output.as_deref().unwrap_or(""); let lines: Vec> = content .lines() .map(|line| { Line::from(Span::styled( line.to_string(), Style::default().fg(theme.text_primary), )) }) .collect(); Some(Self::for_static_content( entry_id, ViewerKind::WebFetch, lines, )) } /// Create a viewer for a read file block (file content with syntax highlighting /// and absolute line numbers, or error text if content is absent). pub fn for_read(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::Read(read)) = &entry.block else { return None; }; let theme = Theme::current(); // Error-only blocks: show the error text if !read.has_content() { let error = read.error.as_deref()?; let error_style = Style::default().fg(theme.accent_error); let lines: Vec> = error .lines() .map(|l| Line::from(Span::styled(l.to_string(), error_style))) .collect(); return Some(Self::for_static_content(entry_id, ViewerKind::Read, lines)); } let content = read.content.as_deref().unwrap_or(""); let raw_lines: Vec<&str> = content.lines().collect(); let base_line = read.line_range.map_or(1, |r| r.start); let max_line = base_line + raw_lines.len().saturating_sub(1); let gutter_width = max_line.checked_ilog10().map_or(1, |d| d as usize + 1); let gutter_style = Style::default().fg(theme.gray_dim); let fallback = Style::default().fg(theme.text_primary); let syntect = crate::syntax::get_syntect(); let mut highlighter = syntect.highlight_lines_by_file_path(std::path::Path::new(&read.path)); let lines: Vec> = raw_lines .iter() .enumerate() .map(|(i, text)| { let gutter = format!("{:>w$} ", base_line + i, w = gutter_width); let mut spans = vec![Span::styled(gutter, gutter_style)]; spans.extend(crate::syntax::highlight_line( text, &mut highlighter, syntect, fallback, )); Line::from(spans) }) .collect(); Some(Self::for_static_content(entry_id, ViewerKind::Read, lines)) } fn static_lines_from_block( entry: &ScrollbackEntry, block: &impl BlockContent, ) -> Vec> { let ctx = BlockContext { mode: DisplayMode::Expanded, is_running: entry.is_running, width: 120, raw: false, max_lines: None, appearance: Default::default(), is_selected: false, cwd: None, }; block .output(&ctx) .lines .into_iter() .map(|bl| bl.content) .collect() } pub fn for_grep(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::Search(search)) = &entry.block else { return None; }; let lines = Self::static_lines_from_block(entry, search); Some(Self::for_static_content(entry_id, ViewerKind::Grep, lines)) } pub fn for_list_dir(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::ListDir(list_dir)) = &entry.block else { return None; }; let lines = Self::static_lines_from_block(entry, list_dir); Some(Self::for_static_content( entry_id, ViewerKind::PlainText, lines, )) } /// Create a viewer for a web search block (search results + citations). pub fn for_web_search(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::WebSearch(ws)) = &entry.block else { return None; }; let theme = Theme::current(); let content = ws.content.as_deref().unwrap_or(""); let mut lines: Vec> = content .lines() .map(|line| { Line::from(Span::styled( line.to_string(), Style::default().fg(theme.text_primary), )) }) .collect(); // Citations footer (separated by a horizontal rule). if !ws.citations.is_empty() { lines.push(Line::from("")); lines.push(Line::from(Span::styled( "───────────────────────────────────", Style::default().fg(theme.gray_dim), ))); lines.push(Line::from(Span::styled( format!("Sources ({})", ws.citations.len()), Style::default().fg(theme.text_secondary), ))); let url_style = Style::default().fg(theme.gray); let prefix_style = Style::default().fg(theme.text_secondary); for (i, url) in ws.citations.iter().enumerate() { let prefix = format!("[{}] ", i + 1); lines.push(Line::from(vec![ Span::styled(prefix, prefix_style), Span::styled(url.clone(), url_style), ])); } } Some(Self::for_static_content( entry_id, ViewerKind::WebSearch, lines, )) } /// Create a viewer for a search_tool block. /// /// Preamble renders the styled header ("Search Tools query"). /// Body shows limit, result count, then the structured tool list. pub fn for_integration_search(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::IntegrationSearch(st)) = &entry.block else { return None; }; let theme = Theme::current(); let label = Style::default().fg(theme.text_secondary); let value = Style::default().fg(theme.text_primary); let dim = Style::default().fg(theme.gray); let mut lines: Vec> = Vec::new(); // Metadata if let Some(limit) = st.limit { lines.push(Line::from(vec![ Span::styled("limit: ", label), Span::styled(limit.to_string(), value), ])); } let s = if st.result_count == 1 { "" } else { "s" }; lines.push(Line::from(Span::styled( format!("{} result{s}", st.result_count), label, ))); // Tool list for (i, tool) in st.results.iter().enumerate() { lines.push(Line::from("")); let action = mcp_titleize_segment(crate::scrollback::blocks::discovered_tool_action(tool)); let server = mcp_titleize_segment(&tool.server); lines.push(Line::from(vec![ Span::styled(format!("{}. ", i + 1), dim), Span::styled(action, Style::default().fg(theme.text_primary)), Span::styled(format!(" {}", server), dim), ])); if !tool.description.is_empty() { lines.push(Line::from(Span::styled( format!(" {}", tool.description), dim, ))); } } Some(Self::for_static_content( entry_id, ViewerKind::IntegrationSearch, lines, )) } /// Create a viewer for a use_tool block. /// /// Preamble renders the styled header ("server action"). /// Body shows input parameters, then the full output. pub fn for_use_tool(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::UseTool(ut)) = &entry.block else { return None; }; let theme = Theme::current(); let label = Style::default().fg(theme.text_secondary); let value = Style::default().fg(theme.text_primary); let mut lines: Vec> = Vec::new(); // Input parameters for (k, v) in &ut.input_args { lines.push(Line::from(vec![ Span::styled(format!("{k}: "), label), Span::styled(v.clone(), value), ])); } // Output or error let content = ut.output.as_deref().or(ut.error.as_deref()); if let Some(text) = content { if !ut.input_args.is_empty() { lines.push(Line::from("")); } let style = if ut.error.is_some() && ut.output.is_none() { Style::default().fg(theme.accent_error) } else { value }; for line in text.lines() { lines.push(Line::from(Span::styled(line.to_string(), style))); } } Some(Self::for_static_content( entry_id, ViewerKind::UseTool, lines, )) } /// Create a viewer for a background task (stdout from central store). /// /// Unlike other viewers that read from the scrollback entry, this one /// takes stdout directly and stores the `task_id` for streaming updates. pub fn for_bg_task(entry_id: EntryId, task_id: &str, stdout: &str, is_running: bool) -> Self { let config = ListPaneConfig { follow_enabled: is_running, wrap_toggle_enabled: true, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: true, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::Wrap, is_running, config); list_state.set_clipboard_provider(Box::new(SystemClipboard)); let theme = Theme::current(); let items = Self::build_execute_items(Some(stdout).filter(|s| !s.is_empty()), &theme); let list_style = ListPaneStyle { selection_bg: theme.bg_highlight, visual_select_bg: theme.bg_visual, uniform_visual_bg: true, ..ListPaneStyle::default() }; Self { entry_id, kind: ViewerKind::BgTask, list_state, list_style, items, last_content_area: Rect::default(), raw_toggle_pending: false, copy_meta_pending: false, copy_content_pending: false, diff_meta: Vec::new(), last_generation: stdout.len() as u64, was_running: is_running, bg_task_id: Some(task_id.to_string()), last_theme: Theme::current_kind(), modal: ModalWindowState::new(), prepend_items: Vec::new(), text_drag: None, drag_copy_text: None, cached_unified: Vec::new(), } } /// Create a viewer for plain text content (e.g., catalog entry). pub fn for_plain_text(title: &str, text: &str) -> Self { let config = ListPaneConfig { follow_enabled: false, wrap_toggle_enabled: true, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: true, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::Wrap, false, config); list_state.set_clipboard_provider(Box::new(SystemClipboard)); let theme = Theme::current(); let title_style = Style::default() .fg(theme.accent_user) .add_modifier(Modifier::BOLD); let style = Style::default().fg(theme.text_primary); let mut items = vec![ ContentLine { content: Line::from(Span::styled(title.to_owned(), title_style)), plain_text: title.to_owned(), id: u64::MAX, bg: None, }, ContentLine { content: Line::default(), plain_text: String::new(), id: u64::MAX - 1, bg: None, }, ]; items.extend(text.lines().enumerate().map(|(i, line)| ContentLine { content: Line::from(Span::styled(line.to_owned(), style)), plain_text: line.to_owned(), id: i as u64, bg: None, })); Self { entry_id: EntryId::new(0), kind: ViewerKind::PlainText, list_state, list_style: ListPaneStyle { uniform_visual_bg: true, ..ListPaneStyle::default() }, items, last_content_area: Rect::default(), raw_toggle_pending: false, copy_meta_pending: false, copy_content_pending: false, diff_meta: Vec::new(), last_generation: 0, was_running: false, bg_task_id: None, last_theme: Theme::current_kind(), modal: ModalWindowState::new(), prepend_items: Vec::new(), text_drag: None, drag_copy_text: None, cached_unified: Vec::new(), } } /// Update a BgTask viewer with new stdout content. /// /// Called from the tick path when stdout in the central store has changed. /// Returns `true` if a redraw is needed. pub fn tick_bg_task(&mut self, stdout: &str, is_running: bool) -> bool { let mut needs_redraw = false; let current_len = stdout.len() as u64; if current_len != self.last_generation { self.last_generation = current_len; let theme = Theme::current(); self.items = Self::build_execute_items(Some(stdout).filter(|s| !s.is_empty()), &theme); self.list_state.invalidate_layout(); needs_redraw = true; } // Running → finished transition: disable follow if self.was_running && !is_running { self.was_running = false; self.list_state.disable_follow_permanently(); if let Some(last) = self.items.last() { self.list_state.select_by_id(last.stable_id()); } needs_redraw = true; } needs_redraw } /// Build content lines from execute stdout output. fn build_execute_items(output: Option<&str>, theme: &Theme) -> Vec { let Some(output) = output else { return Vec::new(); }; let base = ratatui::style::Style::default().fg(theme.text_primary); crate::render::terminal_output::render_terminal_lines(output, base) .into_iter() .enumerate() .map(|(i, rl)| ContentLine { content: rl.line, plain_text: rl.plain, id: i as u64, bg: Some(theme.bg_dark), }) .collect() } /// Create a viewer for an edit block (diff content). pub fn for_edit(entry_id: EntryId, entry: &ScrollbackEntry) -> Option { let RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) = &entry.block else { return None; }; if edit.hunks.is_empty() { return None; } let config = ListPaneConfig { follow_enabled: false, wrap_toggle_enabled: true, search_enabled: true, copy_enabled: true, show_selection_when_unfocused: false, visual_select_enabled: true, filter_enabled: true, goto_line_enabled: false, }; let mut list_state = ListPaneState::new_with_config(WrapMode::Wrap, false, config); list_state.set_clipboard_provider(Box::new(SystemClipboard)); let theme = Theme::current(); let (items, diff_meta) = Self::build_edit_items(edit, &theme); Some(Self { entry_id, kind: ViewerKind::Edit, list_state, list_style: ListPaneStyle { uniform_visual_bg: true, ..ListPaneStyle::default() }, items, last_content_area: Rect::default(), raw_toggle_pending: false, copy_meta_pending: false, copy_content_pending: false, diff_meta, last_generation: 0, was_running: false, bg_task_id: None, last_theme: Theme::current_kind(), modal: ModalWindowState::new(), prepend_items: Vec::new(), text_drag: None, drag_copy_text: None, cached_unified: Vec::new(), }) } /// Build content lines and diff metadata from edit block diff hunks. fn build_edit_items( edit: &crate::scrollback::blocks::EditToolCallBlock, theme: &Theme, ) -> (Vec, Vec>) { use crate::scrollback::blocks::DiffRenderConfig; let config = DiffRenderConfig::default(); // Block-owned dispatch so the viewer paints the same highlight phase // (incl. the file-scoped upgrade) as the scrollback output. let rendered = edit.render_diff_lines( theme, 500, // wide width — NoWrap mode &config, ); // Build a flat list of DiffLine references from all hunks, interleaving // None for separator lines (which render_diff_lines inserts). // The rendered output has: [hunk0 lines...] [separator] [hunk1 lines...] ... let mut meta_source: Vec> = Vec::new(); for (i, hunk) in edit.hunks.iter().enumerate() { if i > 0 && !config.hunk_separator.is_empty() { meta_source.push(None); // separator line } for diff_line in hunk { meta_source.push(Some(diff_line)); // Wrapped lines: render_diff_hunk_highlighted may produce multiple // DiffLineOutput per DiffLine. For now, assume 1:1 mapping since // we use width=500 (very wide, unlikely to wrap). } } let mut items = Vec::with_capacity(rendered.len()); let mut diff_meta = Vec::with_capacity(rendered.len()); for (i, dl) in rendered.into_iter().enumerate() { let plain: String = dl.line.spans.iter().map(|s| s.content.as_ref()).collect(); items.push(ContentLine { content: dl.line, plain_text: plain, id: i as u64, bg: dl.background, }); diff_meta.push(meta_source.get(i).copied().flatten().map(|d| DiffLineMeta { tag: d.tag, text: d.text.clone(), lo: d.lo, ln: d.ln, })); } (items, diff_meta) } /// Extract pre-wrap lines from a markdown block (thinking or agent message). fn extract_markdown_lines(block: &RenderBlock) -> Option>> { match block { RenderBlock::Thinking(b) => Some(b.content().pre_wrap_lines()), RenderBlock::AgentMessage(b) => Some(b.content().pre_wrap_lines()), _ => None, } } /// Extract a change-detection counter from the block. /// /// For markdown blocks: content generation counter. /// For execute blocks: output byte length (no generation counter available). fn extract_generation(block: &RenderBlock) -> Option { match block { RenderBlock::Thinking(b) => Some(b.content().generation()), RenderBlock::AgentMessage(b) => Some(b.content().generation()), RenderBlock::ToolCall(ToolCallBlock::Execute(b)) => { Some(b.output.as_ref().map_or(0, |o| o.len()) as u64) } _ => None, } } /// Extract the line source map from a markdown block. fn extract_line_source_map(block: &RenderBlock) -> Option> { match block { RenderBlock::Thinking(b) => Some(b.content().line_source_map()), RenderBlock::AgentMessage(b) => Some(b.content().line_source_map()), _ => None, } } /// Rebuild items from the block's current content. /// /// Called after raw mode toggle or when content changes during streaming. /// Invalidates the ListPane layout cache since item content/heights may differ. pub fn rebuild_items(&mut self, entry: &ScrollbackEntry) { match self.kind { ViewerKind::Markdown => { if let Some(lines) = Self::extract_markdown_lines(&entry.block) { self.items = ContentLine::from_lines(&lines); self.list_state.invalidate_layout(); } } ViewerKind::Execute => { if let RenderBlock::ToolCall(ToolCallBlock::Execute(exec)) = &entry.block { let theme = Theme::current(); self.items = Self::build_execute_items(exec.output.as_deref(), &theme); self.list_state.invalidate_layout(); } } ViewerKind::Edit => {} ViewerKind::BgTask => {} ViewerKind::Read | ViewerKind::Grep | ViewerKind::WebFetch | ViewerKind::WebSearch | ViewerKind::IntegrationSearch | ViewerKind::UseTool | ViewerKind::PlainText => {} } } /// Look up the source line number for a given stable_id (pre-wrap line index). /// /// Used by the caller to capture cursor position before a raw toggle. pub fn source_line_for_id(block: &RenderBlock, id: u64) -> Option { Self::extract_line_source_map(block).and_then(|map| map.get(id as usize).copied()) } /// Jump the cursor to the first rendered line matching the given source line. /// /// Called after raw toggle + rebuild to restore cursor position. pub fn jump_to_source_line(&mut self, entry: &ScrollbackEntry, target: Option) { let Some(target_source) = target else { return; }; if let Some(new_map) = Self::extract_line_source_map(&entry.block) { let new_idx = new_map .iter() .position(|&sl| sl >= target_source) .unwrap_or(0); self.list_state.select_by_id(new_idx as u64); } } /// Update viewer state on tick (streaming content, follow mode). /// /// Returns `true` if a redraw is needed. pub fn tick(&mut self, entry: &ScrollbackEntry) -> bool { let mut needs_redraw = false; // Check if content changed via generation counter. // Generation is bumped on every push_chunk, finish, or set_raw_mode. if let Some(current_gen) = Self::extract_generation(&entry.block) && current_gen != self.last_generation { self.last_generation = current_gen; self.rebuild_items(entry); needs_redraw = true; } // On running→finished transition: disable follow permanently, // select the last item so the user has a cursor at the bottom. if self.was_running && !entry.is_running { self.was_running = false; self.list_state.disable_follow_permanently(); // Select the last item by stable_id (layout may be stale after // invalidate_layout, so we can't use select_last which reads // layout.item_count). select_by_id is resolved on next prepare_layout. if let Some(last) = self.items.last() { self.list_state.select_by_id(last.stable_id()); } needs_redraw = true; } needs_redraw } /// Build shortcuts bar hints for this viewer. pub fn shortcuts_hints(&self) -> Vec { let mut hints = vec![ HintItem::new(crate::key!(Esc), "close"), HintItem::new(crate::key!('/'), "search"), HintItem::new(crate::key!('f'), "filter"), HintItem::new(crate::key!('v'), "select"), HintItem::new(crate::key!('w'), "wrap"), ]; match self.kind { ViewerKind::Markdown => { hints.push(HintItem::new(crate::key!('r'), "raw")); } ViewerKind::Execute => { hints.push(HintItem::new(crate::key!('Y'), "copy cmd")); } ViewerKind::Edit => { hints.push(HintItem::new(crate::key!('Y'), "copy path")); } ViewerKind::WebFetch => { hints.push(HintItem::new(crate::key!('Y'), "copy url")); } ViewerKind::WebSearch => { hints.push(HintItem::new(crate::key!('Y'), "copy query")); } ViewerKind::Read => { hints.push(HintItem::new(crate::key!('Y'), "copy path")); } ViewerKind::Grep => { hints.push(HintItem::new(crate::key!('Y'), "copy pattern")); } ViewerKind::BgTask => {} ViewerKind::IntegrationSearch | ViewerKind::UseTool | ViewerKind::PlainText => {} } hints } // -- Input handling ------------------------------------------------------ /// Check if a key is a close signal (Esc/q/Ctrl-F). /// /// Separated from `handle_key` so the caller can close the viewer /// before routing the key (avoids borrow conflicts). pub fn is_close_key(&self, key: &KeyEvent) -> bool { // Ctrl-F: close viewer (toggle off) if key.code == KeyCode::Char('f') && key.modifiers.contains(KeyModifiers::CONTROL) { return true; } // Esc / q: close viewer (when input bar and visual select are not active) self.list_state.input_mode().is_none() && !self.list_state.visual_mode && (matches!(key.code, KeyCode::Esc) || (key.code == KeyCode::Char('q') && key.modifiers == KeyModifiers::NONE)) } /// Handle a key event while the viewer is focused. /// /// Returns `true` if the key was consumed, `false` if it should bubble up. /// Close keys should be checked via `is_close_key` before calling this. pub fn handle_key(&mut self, key: &KeyEvent) -> bool { // r: toggle raw mode (markdown blocks only) — handled by caller if self.kind == ViewerKind::Markdown && key.code == KeyCode::Char('r') && key.modifiers == KeyModifiers::NONE && self.list_state.input_mode().is_none() { self.raw_toggle_pending = true; return true; } // Y: copy meta (execute: command, edit: path) — handled by caller if matches!( self.kind, ViewerKind::Execute | ViewerKind::Edit | ViewerKind::Read | ViewerKind::Grep | ViewerKind::WebFetch | ViewerKind::WebSearch ) && key.code == KeyCode::Char('Y') && key.modifiers == KeyModifiers::SHIFT && self.list_state.input_mode().is_none() { self.copy_meta_pending = true; return true; } // y: copy patch format (edit blocks) — intercept before ListPane. // Non-visual: copies full patch (same as scrollback `y`). // Visual: copies patch for the selected range. if self.kind == ViewerKind::Edit && key.code == KeyCode::Char('y') && key.modifiers == KeyModifiers::NONE && self.list_state.input_mode().is_none() { self.copy_content_pending = true; return true; } // Route to ListPaneState — propagate whether the key was consumed. // Pass the same unified vec the layout was prepared against so // selection / scroll math stays in bounds. Any consumed key // (navigation, search, copy, etc.) also clears a sticky mouse // text selection so the highlight doesn't linger across actions. self.rebuild_unified_cache(); let consumed = self.list_state.handle_key_event(key, &self.cached_unified); if consumed { self.text_drag = None; } consumed } /// Generate a patch string from the diff metadata in the given item range. /// /// Returns `None` if this isn't an edit viewer or the range has no diff lines. pub fn patch_from_range(&self, path: &str, range: std::ops::Range) -> Option { if self.diff_meta.is_empty() { return None; } let mut out = String::new(); out.push_str(&format!("--- a/{path}\n")); out.push_str(&format!("+++ b/{path}\n")); // Collect non-None entries in the range let entries: Vec<&DiffLineMeta> = range .filter_map(|i| self.diff_meta.get(i).and_then(|m| m.as_ref())) .collect(); if entries.is_empty() { return None; } // Compute hunk header let old_start = entries .iter() .filter(|e| e.tag != similar::ChangeTag::Insert) .map(|e| e.lo) .next() .unwrap_or(1); let new_start = entries .iter() .filter(|e| e.tag != similar::ChangeTag::Delete) .map(|e| e.ln) .next() .unwrap_or(1); let old_count = entries .iter() .filter(|e| e.tag != similar::ChangeTag::Insert) .count(); let new_count = entries .iter() .filter(|e| e.tag != similar::ChangeTag::Delete) .count(); out.push_str(&format!( "@@ -{old_start},{old_count} +{new_start},{new_count} @@\n" )); for entry in &entries { let prefix = match entry.tag { similar::ChangeTag::Equal => ' ', similar::ChangeTag::Insert => '+', similar::ChangeTag::Delete => '-', }; let text = entry.text.trim_end_matches(['\r', '\n']); out.push(prefix); out.push_str(text); out.push('\n'); } Some(out) } /// Process pending actions (copy meta, copy content) and return text to copy. /// /// Call after `handle_key` or `handle_mouse`. The caller is responsible for /// clipboard operations and toast display. Returns `None` if no copy pending. pub fn process_pending_copy(&mut self, entry: &ScrollbackEntry) -> Option { // Y — copy command (execute blocks) if self.copy_meta_pending { self.copy_meta_pending = false; return entry.block.copy_meta().filter(|t| !t.is_empty()); } // y — copy content (edit blocks: patch format; others: block content) if self.copy_content_pending { self.copy_content_pending = false; let result = if self.kind == ViewerKind::Edit { // Patch from copy range (visual selection or current line). // copy_range() returns indices into the unified vec (prepend + // items), but diff_meta is parallel to items only. Adjust by // subtracting the prepend offset so we index diff_meta correctly. let copy_range = self.list_state.copy_range(); let path = match &entry.block { RenderBlock::ToolCall(ToolCallBlock::Edit(edit)) => Some(&edit.path), _ => None, }; match (copy_range, path) { (Some(range), Some(path)) => { let offset = self.prepend_items.len(); let adj_start = range.start.saturating_sub(offset); let adj_end = range.end.saturating_sub(offset); if adj_start < adj_end { self.patch_from_range(path, adj_start..adj_end) } else { None } } _ => entry.block.copy_text(entry.raw), } } else { entry.block.copy_text(entry.raw) }; self.list_state.exit_visual_mode(); return result.filter(|t| !t.is_empty()); } None } /// Rebuild the cached unified items vec (preamble + body). The cache /// retains its allocation across calls so repeated rebuilds within a /// frame avoid fresh heap allocations. Callers that hold `&mut self` /// call this first, then use `&self.cached_unified` via a disjoint /// field borrow alongside `&mut self.list_state` etc. fn rebuild_unified_cache(&mut self) { self.cached_unified.clear(); self.cached_unified .extend(self.prepend_items.iter().cloned()); self.cached_unified.extend(self.items.iter().cloned()); } /// Handle mouse scroll in the viewer area. pub fn handle_scroll(&mut self, lines: i32) { self.rebuild_unified_cache(); self.list_state.scroll_lines(lines, &self.cached_unified); } /// Handle mouse click/drag in the viewer area. /// /// Drag-to-highlight: a left-button drag inside the content area paints /// a character-precise selection (same model as the scrollback pane). /// Anchor and head are STABLE references (item index + char offset in /// the item's logical line), so the selection survives scrolling and /// the auto-copy on release includes any content the drag covered even /// if it's now off-screen. The selection stays visible after release /// until the next click. pub fn handle_mouse(&mut self, kind: MouseEventKind, col: u16, row: u16) -> bool { use crossterm::event::{MouseButton, MouseEventKind as MEK}; self.rebuild_unified_cache(); let pane_area = self.last_content_area; let scrollbar_x_range = self .list_state .scrollbar_area() .map(|sb| (sb.x, sb.x + sb.width)); // Is this position inside the content area but not on the scrollbar? let in_content = |c: u16, r: u16| -> bool { r >= pane_area.y && r < pane_area.y + pane_area.height && c >= pane_area.x && c < pane_area.x + pane_area.width && match scrollbar_x_range { Some((lo, hi)) => !(c >= lo && c < hi), None => true, } }; match kind { MEK::Down(MouseButton::Left) if in_content(col, row) => { // Start a fresh text selection anchored at the clicked // character. Also clear any keyboard visual-mode highlight // so the user only sees the new mouse selection. self.list_state.exit_visual_mode(); if let Some(ep) = self.screen_to_endpoint(col, row, &self.cached_unified) { self.text_drag = Some(TextDrag { anchor: ep, head: ep, active: true, }); } true } MEK::Drag(MouseButton::Left) => { if let Some(drag) = self.text_drag && drag.active { // Auto-scroll when the cursor drags above or below the // content area (same idea as scrollback drag_autoscroll). // Speed scales with distance from the edge (1..5 rows). if row < pane_area.y { let distance = pane_area.y.saturating_sub(row) as i32; self.list_state .scroll_lines(-distance.clamp(1, 5), &self.cached_unified); } else if row >= pane_area.y + pane_area.height { let distance = (row.saturating_sub(pane_area.y + pane_area.height) + 1) as i32; self.list_state .scroll_lines(distance.clamp(1, 5), &self.cached_unified); } // Clamp the cursor to the content area then map to an // item endpoint so the selection extends as we scroll. let max_c = pane_area.x + pane_area.width.saturating_sub(1); let max_r = pane_area.y + pane_area.height.saturating_sub(1); let cc = col.clamp(pane_area.x, max_c); let rr = row.clamp(pane_area.y, max_r); if let Some(ep) = self.screen_to_endpoint(cc, rr, &self.cached_unified) && let Some(d) = self.text_drag.as_mut() { d.head = ep; } return true; } false } MEK::Up(MouseButton::Left) => { if let Some(drag) = self.text_drag && drag.active { // Update head to the release position — the last // Drag(Left) may trail the actual release by a few // characters, causing 1-2 missing words at the end. let mut final_drag = drag; let max_c = pane_area.x + pane_area.width.saturating_sub(1); let max_r = pane_area.y + pane_area.height.saturating_sub(1); let cc = col.clamp(pane_area.x, max_c); let rr = row.clamp(pane_area.y, max_r); if let Some(ep) = self.screen_to_endpoint(cc, rr, &self.cached_unified) { final_drag.head = ep; } if final_drag.is_non_empty() { self.drag_copy_text = self.text_for_drag(final_drag, &self.cached_unified); } self.text_drag = None; return true; } // Click without drag — just clear. self.text_drag = None; true } // Any other button-down (including a left-click outside the // content area) clears a sticky selection so the highlight // doesn't linger after the user moves on. MEK::Down(_) => { self.text_drag = None; self.list_state .handle_mouse_event(kind, col, row, pane_area, &self.cached_unified) } _ => { self.list_state .handle_mouse_event(kind, col, row, pane_area, &self.cached_unified) } } } /// Translate a screen position inside the content area into a stable /// `TextEndpoint` (item index + char/display-column offset within the /// item's logical line text). /// /// Returns `None` if the position doesn't map to any item (e.g. blank /// space below the last visual row of the content). The result is /// clamped: clicks beyond the end of an item's last sub-row map to /// `col = end_of_logical_line`, and clicks past the rightmost char on /// a sub-row map to that sub-row's end column. fn screen_to_endpoint( &self, col: u16, row: u16, items: &[ContentLine], ) -> Option { let pane = self.last_content_area; if row < pane.y || col < pane.x { return None; } let virtual_y = self.list_state.scroll_offset() + (row - pane.y) as usize; let item_idx = self.list_state.layout().item_at_y(virtual_y)?; let item = items.get(item_idx)?; let item_top = self.list_state.layout().virtual_y(item_idx); let sub_row = virtual_y.saturating_sub(item_top) as u16; let col_in_sub = col.saturating_sub(pane.x); // Re-wrap this item's content at the same width the layout used. // Use joiners to account for spaces dropped at each break point — // without them, absolute_col drifts by 1+ chars per wrap break. let wrap_w = self.effective_wrap_width(); let (wrapped, joiners) = self.wrap_item_with_joiners(item, wrap_w); let mut absolute_col: u16 = 0; for (i, line) in wrapped.iter().enumerate() { // Add joiner width (spaces dropped at the break point). if i > 0 && let Some(Some(j)) = joiners.get(i) { absolute_col = absolute_col .saturating_add(unicode_width::UnicodeWidthStr::width(j.as_str()) as u16); } let line_w = line_display_width_u16(line); if i == sub_row as usize { if col_in_sub >= line_w && i + 1 < wrapped.len() { // Cursor at/past the right edge of a non-last sub-row: // snap to end of logical line. let text = item.copy_text(); let total = unicode_width::UnicodeWidthStr::width(text.as_str()) as u16; return Some(TextEndpoint { item_idx, col: total, }); } absolute_col = absolute_col.saturating_add(col_in_sub.min(line_w)); return Some(TextEndpoint { item_idx, col: absolute_col, }); } absolute_col = absolute_col.saturating_add(line_w); } // sub_row past the wrapped output → clamp to end of last sub-row. // (This also handles items shorter than the layout item_height — // shouldn't happen, but keeps the click meaningful.) Some(TextEndpoint { item_idx, col: absolute_col, }) } /// Effective wrap width used by the ListPane for this frame: pane width /// minus the scrollbar gutter when a scrollbar is shown. fn effective_wrap_width(&self) -> u16 { let pane = self.last_content_area; let pane_right = pane.x + pane.width; match self.list_state.scrollbar_area() { // Scrollbar starts at sb.x; usable text width is sb.x - pane.x. Some(sb) if sb.x >= pane.x && sb.x < pane_right => sb.x - pane.x, _ => pane.width, } } /// Wrap one item's content, returning wrapped lines and the per-break /// joiners (spaces dropped at each break point). The joiner widths /// are needed to correctly map screen positions to display-column /// offsets in the original unwrapped text. fn wrap_item_with_joiners( &self, item: &ContentLine, width: u16, ) -> (Vec>, Vec>) { if width == 0 { return (vec![item.content.clone()], vec![None]); } let (wrapped, joiners) = crate::render::wrapping::word_wrap_line_with_joiners(&item.content, width as usize); if wrapped.is_empty() { (vec![item.content.clone()], vec![None]) } else { let lines = wrapped .iter() .map(crate::render::line_utils::line_to_static) .collect(); (lines, joiners) } } /// Advance a display-column past the grapheme that occupies it. /// Returns the exclusive end column for `slice_display_cols`. fn col_past_char(text: &str, col: u16) -> u16 { use unicode_segmentation::UnicodeSegmentation; use unicode_width::UnicodeWidthStr; let mut c = 0u16; for g in text.graphemes(true) { let w = (g.width() as u16).max(1); let next = c.saturating_add(w); if next > col { return next; } c = next; } // Past last character — return total width. c } /// Extract the selected text from the unified items list. /// Column-precise: slices partial lines at the start/end of the /// selection, full lines in the middle. fn text_for_drag(&self, drag: TextDrag, items: &[ContentLine]) -> Option { let (start, end) = drag.ordered(); let mut out = String::new(); let mut wrote_any = false; for idx in start.item_idx..=end.item_idx { let Some(item) = items.get(idx) else { break; }; let text = item.copy_text(); let slice = if start.item_idx == end.item_idx { // Single-item: slice [start .. end_inclusive] let hi = Self::col_past_char(&text, end.col); crate::scrollback::types::slice_display_cols(&text, start.col, hi) } else if idx == start.item_idx { // First item: from start to end of line crate::scrollback::types::slice_display_cols(&text, start.col, u16::MAX) } else if idx == end.item_idx { // Last item: from beginning to end_inclusive let hi = Self::col_past_char(&text, end.col); crate::scrollback::types::slice_display_cols(&text, 0, hi) } else { // Middle items: full line text }; if wrote_any { out.push('\n'); } out.push_str(&slice); wrote_any = true; } if out.is_empty() { None } else { Some(out) } } /// Paint the active / sticky text selection overlay onto `buf`. /// /// Called by the modal renderer AFTER `render_content` has drawn the /// items. Walks the visible portion of the selection (off-screen /// portions are hidden, but they're still in the copy text), inverts /// fg/bg on every cell that falls inside the per-sub-row column range. pub fn render_text_drag_overlay(&self, buf: &mut ratatui::buffer::Buffer) { let Some(drag) = self.text_drag else { return; }; if !drag.is_non_empty() { return; } let theme = Theme::current(); let pane = self.last_content_area; if pane.width == 0 || pane.height == 0 { return; } let (start, end) = drag.ordered(); // Advance end past the character under the cursor so the // highlight includes the pointed-at character (matches copy). let end_col_hi = self .cached_unified .get(end.item_idx) .map(|item| Self::col_past_char(&item.copy_text(), end.col)) .unwrap_or(end.col); let end = TextEndpoint { item_idx: end.item_idx, col: end_col_hi, }; let scroll = self.list_state.scroll_offset(); let pane_top = pane.y; let pane_bottom = pane.y + pane.height; let wrap_w = self.effective_wrap_width(); for idx in start.item_idx..=end.item_idx { let Some(item) = self.cached_unified.get(idx) else { break; }; let item_top = self.list_state.layout().virtual_y(idx); let item_h = self.list_state.layout().item_height(idx) as usize; // Skip items entirely above / below the viewport. if item_top + item_h <= scroll { continue; } if item_top >= scroll + pane.height as usize { break; } let (wrapped, joiners) = self.wrap_item_with_joiners(item, wrap_w); // Selection covers chars [item_lo .. item_hi) of this item's // logical line. let (item_lo, item_hi) = if start.item_idx == end.item_idx { (start.col, end.col) } else if idx == start.item_idx { (start.col, u16::MAX) } else if idx == end.item_idx { (0, end.col) } else { (0, u16::MAX) }; // Walk this item's sub-rows. acc_col tracks the absolute // column in the original text, including joiner widths. let mut acc_col: u16 = 0; for (sub_i, line) in wrapped.iter().enumerate() { if sub_i > 0 && let Some(Some(j)) = joiners.get(sub_i) { acc_col = acc_col .saturating_add(unicode_width::UnicodeWidthStr::width(j.as_str()) as u16); } let line_w = line_display_width_u16(line); let sub_start = acc_col; let sub_end = acc_col.saturating_add(line_w); acc_col = sub_end; // Compute selection overlap in absolute item-cols. let sel_start = item_lo.max(sub_start); let sel_end = item_hi.min(sub_end); if sel_end <= sel_start { continue; } // Translate to screen coordinates. let virtual_y = item_top + sub_i; if virtual_y < scroll { continue; } let screen_y = pane_top + (virtual_y - scroll) as u16; if screen_y >= pane_bottom { break; } let local_lo = sel_start - sub_start; let local_hi = sel_end - sub_start; let x_lo = pane.x + local_lo; let x_hi = pane.x + local_hi; let x_lo = x_lo.min(pane.x + pane.width); let x_hi = x_hi.min(pane.x + pane.width); for x in x_lo..x_hi { if let Some(cell) = buf.cell_mut((x, screen_y)) { crate::scrollback::text_selection::apply_selection_highlight(&theme, cell); } } } } } } /// Display width of a `Line` in terminal columns, clamped to `u16::MAX`. fn line_display_width_u16(line: &Line<'_>) -> u16 { use unicode_width::UnicodeWidthStr; let w: usize = line.spans.iter().map(|s| s.content.as_ref().width()).sum(); w.min(u16::MAX as usize) as u16 } impl BlockViewerPane { // -- Rendering ----------------------------------------------------------- /// Render the viewer content into the given area (provided by modal chrome). /// /// `content_area` is the inner content rect returned by `render_modal_window`. /// `entry` is the scrollback entry being viewed (for theme rebuild). /// `prepend_lines` are pre-wrapped header lines to render at the top of /// the scrollable content (preamble + a blank separator). They get their /// own stable IDs in the high half of the u64 space so they don't /// conflict with content item IDs. pub fn render_content( &mut self, content_area: Rect, buf: &mut Buffer, entry: &ScrollbackEntry, focused: bool, prepend_lines: &[Line<'static>], ) { let theme = Theme::current(); // Detect theme switch and rebuild items + list style. let current_theme = Theme::current_kind(); if current_theme != self.last_theme { self.last_theme = current_theme; self.list_style = match self.kind { ViewerKind::BgTask | ViewerKind::Execute => ListPaneStyle { selection_bg: theme.bg_highlight, visual_select_bg: theme.bg_visual, uniform_visual_bg: true, ..ListPaneStyle::default() }, _ => ListPaneStyle { uniform_visual_bg: true, ..ListPaneStyle::default() }, }; match self.kind { ViewerKind::Execute => { let output = entry.block.copy_text(entry.raw); self.items = Self::build_execute_items( output.as_deref().filter(|s| !s.is_empty()), &theme, ); self.list_state.invalidate_layout(); } ViewerKind::BgTask => { self.last_generation = u64::MAX; } _ => {} } } self.last_content_area = content_area; // Cache prepend lines as ContentLines on self so the input handlers // (scroll / mouse / key) can rebuild the same unified vec the // ListPane saw — otherwise they index a smaller `items` vec and // panic when the cursor / scroll math points past its end. // Header IDs are placed in the high u64 range so they never collide // with content IDs (which start at 0 and grow upward). self.prepend_items = prepend_lines .iter() .enumerate() .map(|(i, line)| { let plain: String = line.spans.iter().map(|s| s.content.as_ref()).collect(); ContentLine { content: line.clone(), plain_text: plain, id: u64::MAX - i as u64, bg: line.style.bg, } }) .collect(); self.rebuild_unified_cache(); if content_area.height > 0 && content_area.width > 0 && !self.cached_unified.is_empty() { let likely_scrollbar = self.cached_unified.len() > content_area.height as usize; let render_area = if likely_scrollbar { Rect { width: content_area.width + SCROLLBAR_TOTAL_COLS.min(2), ..content_area } } else { content_area }; self.list_state.prepare_layout( &self.cached_unified, render_area.width, render_area.height, ); let render_area = if !likely_scrollbar && self.list_state.total_height() > render_area.height as usize { let wider = Rect { width: content_area.width + SCROLLBAR_TOTAL_COLS.min(2), ..content_area }; self.list_state .prepare_layout(&self.cached_unified, wider.width, wider.height); wider } else { render_area }; ListPane::new(&self.cached_unified) .focused(focused) .style(self.list_style) .render(render_area, buf, &mut self.list_state); } } }