Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/block_viewer.rs
T
ZacharyZhang-NY d6c20fc13f M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.

Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
  kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
  ptyctl, ptyctl-cli, third_party/ unchanged; proto package
  xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
  KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
  (templates re-encrypted)

Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
  trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
  module & dc_log, heap-profile uploader, auth-diagnostics uploader,
  session-analytics halves of feedback; local zero-egress observability
  preserved in new kigi-log crate (unified log, --debug firehose,
  subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
  direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
  relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
  ~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
  kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
  session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
  shell util

Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
  https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
  https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
  Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted

Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
  workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
  all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
  exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
  insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean

Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
  (new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
  fast-worktree); RSS measurement tests serialized via serial_test

Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
  notices sustained; kigi-tools ported-code notices extended; README,
  CONTRIBUTING, SECURITY, AGENTS.md rewritten

Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
2026-07-17 05:31:01 -04:00

1795 lines
68 KiB
Rust

//! 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<Color>,
}
impl ContentLine {
/// Build content lines from pre-wrap rendered markdown lines.
pub fn from_lines(lines: &[Line<'static>]) -> Vec<Self> {
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<Color> {
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<ContentLine>,
/// 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<Option<DiffLineMeta>>,
/// 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<String>,
/// 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<ContentLine>,
/// 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<TextDrag>,
/// 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<String>,
/// 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<ContentLine>,
}
/// 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<Self> {
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<Self> {
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<Line<'static>>) -> 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<ContentLine> = 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<Self> {
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<Line<'static>> = 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<Self> {
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<Line<'static>> = 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<Line<'static>> = 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<Line<'static>> {
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<Self> {
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<Self> {
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<Self> {
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<Line<'static>> = 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<Self> {
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<Line<'static>> = 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<Self> {
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<Line<'static>> = 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<ContentLine> {
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<Self> {
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<ContentLine>, Vec<Option<DiffLineMeta>>) {
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<Option<&crate::diff::DiffLine>> = 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<Vec<Line<'static>>> {
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<u64> {
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<Vec<usize>> {
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<usize> {
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<usize>) {
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<HintItem> {
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<usize>) -> Option<String> {
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<String> {
// 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<TextEndpoint> {
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<Line<'static>>, Vec<Option<String>>) {
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<String> {
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);
}
}
}