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).
1795 lines
68 KiB
Rust
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);
|
|
}
|
|
}
|
|
}
|