Files
Kigi-CLI/crates/codegen/kigi-tui/src/views/file_search/line_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

1823 lines
66 KiB
Rust

//! Line viewer popup for selecting line ranges from a file.
//!
//! A centered modal overlay showing syntax-highlighted file content with
//! line numbers. Backed by [`ListPaneState`] for navigation, visual selection,
//! and search. Used to build `@foo/bar.rs:10-12` line references.
//!
//! ## Lifecycle
//!
//! 1. Opened via `:` in dropdown, `Ctrl-L` on element, or `<left>:` after element.
//! 2. User navigates with j/k, searches with `/`, selects range with `v`.
//! 3. **Enter** confirms → element updated with line range, undo group closed.
//! 4. **Esc** cancels → undo group cancelled, reverts to pre-viewer state.
use std::ops::Range;
use std::path::{Path, PathBuf};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{StatefulWidget, Widget};
use syntect::easy::HighlightLines;
use crate::render::scrollbar::SCROLLBAR_TOTAL_COLS;
use crate::render::wrapping::word_wrap_line;
use crate::scrollback::blocks::markdown_content::MarkdownContent;
use crate::syntax::get_syntect;
use crate::theme::Theme;
use crate::views::list_pane::{
ListItem, ListPane, ListPaneConfig, ListPaneState, ListPaneStyle, WrapMode,
};
use kigi_ratatui_textarea::ElementId;
// ── Line item ───────────────────────────────────────────────────────────
/// A single source line for the line viewer.
///
/// In normal mode, each item has one `content` line (syntax-highlighted source).
/// In markdown mode, `rendered_lines` holds the rendered markdown output for this
/// source line (may be multiple visual lines, e.g., table with borders). The item
/// uses custom `render()` and `desired_height()` for multi-line display.
#[derive(Clone)]
pub struct SourceLine {
/// 1-based line number (for display and `@file:N-M` references).
line_number: usize,
/// Unique item ID for ListPane selection tracking. In normal mode this
/// equals `line_number`. In markdown mode, source lines can repeat
/// (e.g., table borders) so we use a monotonic counter instead.
item_id: u64,
/// Styled content (syntax highlighted). Used in normal mode.
content: Line<'static>,
/// Prefix: right-aligned line number (dim — default).
prefix: Line<'static>,
/// Prefix for visual selection range (medium brightness).
prefix_in_selection: Line<'static>,
/// Prefix for the cursor line (brightest).
prefix_cursor: Line<'static>,
/// Blank prefix for continuation lines in multi-line items.
prefix_blank: Line<'static>,
/// Plain text for search matching.
plain_text: String,
/// Rendered markdown lines (empty in normal mode).
rendered_lines: Vec<Line<'static>>,
/// Per-rendered-line background color (for code blocks).
rendered_bgs: Vec<Option<Color>>,
/// Whether this source line has a comment attached (for highlight).
pub commented: bool,
}
impl SourceLine {
fn new(
line_number: usize,
content: Line<'static>,
plain_text: String,
max_digits: usize,
) -> Self {
let (prefix, prefix_in_selection, prefix_cursor, prefix_blank) =
build_prefixes(line_number, max_digits);
Self {
line_number,
item_id: line_number as u64,
content,
prefix,
prefix_in_selection,
prefix_cursor,
prefix_blank,
plain_text,
rendered_lines: Vec::new(),
rendered_bgs: Vec::new(),
commented: false,
}
}
fn new_markdown(
item_id: u64,
line_number: usize,
rendered_lines: Vec<Line<'static>>,
rendered_bgs: Vec<Option<Color>>,
plain_text: String,
max_digits: usize,
) -> Self {
let (prefix, prefix_in_selection, prefix_cursor, prefix_blank) =
build_prefixes(line_number, max_digits);
Self {
line_number,
item_id,
content: Line::default(),
prefix,
prefix_in_selection,
prefix_cursor,
prefix_blank,
plain_text,
rendered_lines,
rendered_bgs,
commented: false,
}
}
fn is_markdown(&self) -> bool {
!self.rendered_lines.is_empty()
}
}
fn build_prefixes(
line_number: usize,
max_digits: usize,
) -> (Line<'static>, Line<'static>, Line<'static>, Line<'static>) {
let theme = Theme::current();
let num_str = format!("{:>width$} ", line_number, width = max_digits);
let blank_str = " ".repeat(num_str.len());
let prefix = Line::from(Span::styled(
num_str.clone(),
Style::default().fg(theme.gray_dim),
));
let prefix_in_selection = Line::from(Span::styled(
num_str.clone(),
Style::default().fg(theme.gray),
));
let prefix_cursor = Line::from(Span::styled(
num_str,
Style::default().fg(theme.text_secondary),
));
let prefix_blank = Line::from(Span::styled(blank_str, Style::default().fg(theme.gray_dim)));
(prefix, prefix_in_selection, prefix_cursor, prefix_blank)
}
impl ListItem for SourceLine {
fn content(&self) -> &Line<'_> {
if self.is_markdown() {
// Empty content triggers custom render().
static EMPTY: std::sync::LazyLock<Line<'static>> =
std::sync::LazyLock::new(Line::default);
&EMPTY
} else {
&self.content
}
}
fn prefix(&self) -> Option<Line<'_>> {
if self.commented {
Some(self.prefix_in_selection.clone())
} else {
Some(self.prefix.clone())
}
}
fn prefix_in_selection(&self) -> Option<Line<'_>> {
Some(self.prefix_in_selection.clone())
}
fn prefix_cursor(&self) -> Option<Line<'_>> {
Some(self.prefix_cursor.clone())
}
fn stable_id(&self) -> u64 {
self.item_id
}
fn search_text(&self) -> &str {
&self.plain_text
}
fn copy_text(&self) -> String {
if self.is_markdown() {
self.plain_text.clone()
} else {
self.content
.spans
.iter()
.map(|s| s.content.as_ref())
.collect()
}
}
fn desired_height(&self, width: u16) -> u16 {
if !self.is_markdown() || width == 0 {
return 1;
}
let prefix_w = self
.prefix()
.map(|p| crate::views::list_pane::line_display_width(&p))
.unwrap_or(0);
let text_w = (width as usize).saturating_sub(prefix_w).max(1);
let mut total: u16 = 0;
for line in &self.rendered_lines {
let wrapped = word_wrap_line(line, text_w);
total += (wrapped.len() as u16).max(1);
}
total.max(1)
}
fn goto_line_number(&self) -> Option<usize> {
Some(self.line_number)
}
fn render(&self, area: Rect, buf: &mut Buffer, _selected: bool, _focused: bool) {
if !self.is_markdown() || area.height == 0 || area.width == 0 {
return;
}
let theme = Theme::current();
let prefix_w = self
.prefix()
.map(|p| crate::views::list_pane::line_display_width(&p))
.unwrap_or(0) as u16;
let text_x = area.x + prefix_w;
let text_w = area.width.saturating_sub(prefix_w);
// Apply commented background tint to the full item area.
if self.commented {
buf.set_style(area, Style::default().bg(theme.bg_visual));
}
// Render prefix on the first visual line (brighter when commented).
let pfx = if self.commented {
&self.prefix_in_selection
} else {
&self.prefix
};
buf.set_line(area.x, area.y, pfx, prefix_w);
let mut y = area.y;
let mut is_first_visual = true;
for (i, line) in self.rendered_lines.iter().enumerate() {
let bg = self.rendered_bgs.get(i).copied().flatten();
let wrapped = word_wrap_line(line, text_w as usize);
let visual_lines = if wrapped.is_empty() {
vec![Line::default()]
} else {
wrapped
};
for wline in &visual_lines {
if y >= area.y + area.height {
break;
}
if let Some(bg) = bg {
let bg_rect = Rect {
x: text_x,
y,
width: text_w,
height: 1,
};
buf.set_style(bg_rect, Style::default().bg(bg));
}
if !is_first_visual {
buf.set_line(area.x, y, &self.prefix_blank, prefix_w);
}
buf.set_line(text_x, y, wline, text_w);
y += 1;
is_first_visual = false;
}
}
}
}
// ── Comment lines ─────────────────────────────────────────────────────
/// An inline review comment displayed between source lines.
pub struct CommentLine {
pub comment_id: u64,
item_id: u64,
line_label: String,
text: String,
prefix: Line<'static>,
}
impl CommentLine {
pub fn new(
comment_id: u64,
item_id: u64,
line_range: &std::ops::Range<usize>,
text: String,
max_digits: usize,
) -> Self {
let theme = Theme::current();
let pad = " ".repeat(max_digits);
let prefix = Line::from(Span::styled(
format!("{pad} "),
Style::default().fg(theme.accent_plan),
));
let line_label = if line_range.len() == 1 {
format!("L{}", line_range.start)
} else {
format!("L{}-{}", line_range.start, line_range.end - 1)
};
Self {
comment_id,
item_id,
line_label,
text,
prefix,
}
}
}
impl ListItem for CommentLine {
fn content(&self) -> &Line<'_> {
static EMPTY: std::sync::LazyLock<Line<'static>> = std::sync::LazyLock::new(Line::default);
&EMPTY
}
fn prefix(&self) -> Option<Line<'_>> {
Some(self.prefix.clone())
}
fn prefix_in_selection(&self) -> Option<Line<'_>> {
Some(self.prefix.clone())
}
fn prefix_cursor(&self) -> Option<Line<'_>> {
Some(self.prefix.clone())
}
fn stable_id(&self) -> u64 {
self.item_id
}
fn search_text(&self) -> &str {
&self.text
}
fn copy_text(&self) -> String {
self.text.clone()
}
fn desired_height(&self, width: u16) -> u16 {
if width == 0 {
return 1;
}
let prefix_w =
crate::views::list_pane::line_display_width(&self.prefix().unwrap_or_default());
let text_w = (width as usize).saturating_sub(prefix_w).max(1);
self.text
.split('\n')
.enumerate()
.map(|(i, text_line)| {
let display = if i == 0 {
format!(
"{} {} {}",
crate::glyphs::filled_dot(),
self.line_label,
text_line
)
} else {
format!(" {text_line}")
};
word_wrap_line(&Line::from(display), text_w).len() as u16
})
.sum::<u16>()
.max(1)
}
fn render(&self, area: Rect, buf: &mut Buffer, _selected: bool, _focused: bool) {
if area.height == 0 || area.width == 0 {
return;
}
let theme = Theme::current();
let prefix_w =
crate::views::list_pane::line_display_width(&self.prefix().unwrap_or_default()) as u16;
let text_x = area.x + prefix_w;
let text_w = area.width.saturating_sub(prefix_w);
buf.set_line(area.x, area.y, &self.prefix, prefix_w);
let mut y = area.y;
for (i, text_line) in self.text.split('\n').enumerate() {
if y >= area.y + area.height {
break;
}
let line = if i == 0 {
let bullet = Span::styled(
format!("{} ", crate::glyphs::filled_dot()),
Style::default().fg(theme.accent_plan),
);
let label = Span::styled(
format!("{} ", self.line_label),
Style::default().fg(theme.gray),
);
let body = Span::styled(
text_line.to_owned(),
Style::default().fg(theme.text_primary),
);
Line::from(vec![bullet, label, body])
} else {
let indent = Span::styled(" ", Style::default().fg(theme.accent_plan));
let body = Span::styled(
text_line.to_owned(),
Style::default().fg(theme.text_primary),
);
Line::from(vec![indent, body])
};
let wrapped = word_wrap_line(&line, text_w as usize);
for wline in &wrapped {
if y >= area.y + area.height {
break;
}
buf.set_line(text_x, y, wline, text_w);
y += 1;
}
}
}
}
// ── Plan viewer item ──────────────────────────────────────────────────
/// A viewer item: either a source line or an inline review comment.
pub enum PlanViewerItem {
Source(Box<SourceLine>),
Comment(CommentLine),
}
impl PlanViewerItem {
/// The 1-based source line number, if this is a source line.
pub fn line_number(&self) -> Option<usize> {
match self {
Self::Source(s) => Some(s.line_number),
Self::Comment(_) => None,
}
}
/// The comment ID, if this is a comment item.
pub fn comment_id(&self) -> Option<u64> {
match self {
Self::Source(_) => None,
Self::Comment(c) => Some(c.comment_id),
}
}
}
impl ListItem for PlanViewerItem {
fn content(&self) -> &Line<'_> {
match self {
Self::Source(s) => s.content(),
Self::Comment(c) => c.content(),
}
}
fn prefix(&self) -> Option<Line<'_>> {
match self {
Self::Source(s) => s.prefix(),
Self::Comment(c) => c.prefix(),
}
}
fn prefix_in_selection(&self) -> Option<Line<'_>> {
match self {
Self::Source(s) => s.prefix_in_selection(),
Self::Comment(c) => c.prefix_in_selection(),
}
}
fn prefix_cursor(&self) -> Option<Line<'_>> {
match self {
Self::Source(s) => s.prefix_cursor(),
Self::Comment(c) => c.prefix_cursor(),
}
}
fn stable_id(&self) -> u64 {
match self {
Self::Source(s) => s.stable_id(),
Self::Comment(c) => c.stable_id(),
}
}
fn is_selectable(&self) -> bool {
true
}
fn search_text(&self) -> &str {
match self {
Self::Source(s) => s.search_text(),
Self::Comment(c) => c.search_text(),
}
}
fn copy_text(&self) -> String {
match self {
Self::Source(s) => s.copy_text(),
Self::Comment(c) => c.copy_text(),
}
}
fn desired_height(&self, width: u16) -> u16 {
match self {
Self::Source(s) => s.desired_height(width),
Self::Comment(c) => c.desired_height(width),
}
}
fn render(&self, area: Rect, buf: &mut Buffer, selected: bool, focused: bool) {
match self {
Self::Source(s) => s.render(area, buf, selected, focused),
Self::Comment(c) => c.render(area, buf, selected, focused),
}
}
fn goto_line_number(&self) -> Option<usize> {
match self {
Self::Source(s) => Some(s.line_number),
Self::Comment(_) => None,
}
}
}
// ── Viewer state ────────────────────────────────────────────────────────
/// What kind of content the line viewer is showing.
///
/// Replaces string-based type sniffing (`title_override == Some("plan.md")`)
/// with a typed enum so that plan-specific behavior (commenting, approval,
/// double-click, shortcuts) can be dispatched via `match` rather than
/// string comparison.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum LineViewerKind {
/// Normal file preview opened from an `@file` reference.
#[default]
FilePreview,
/// Plan document preview (plan.md) — supports commenting, approval
/// buttons, send-feedback, and double-click-to-comment.
PlanPreview,
}
#[derive(Default)]
pub struct PlanViewerExtras {
pub send_button_area: Option<Rect>,
pub send_hovered: bool,
pub show_action_buttons: bool,
pub feedback_active: bool,
pub approve_button_area: Option<Rect>,
pub approve_hovered: bool,
pub comment_button_area: Option<Rect>,
pub comment_hovered: bool,
pub abandon_button_area: Option<Rect>,
pub abandon_hovered: bool,
pub last_click_at: Option<std::time::Instant>,
pub gutter_drag_start: Option<usize>,
pub gutter_drag_end: Option<usize>,
pub gutter_hovered_line: Option<usize>,
pub active_commenting_range: Option<std::ops::Range<usize>>,
}
/// Double-click detection threshold in milliseconds.
pub const DOUBLE_CLICK_MS: u128 = 400;
/// State for the line viewer popup.
pub struct LineViewerState {
/// What kind of content is being viewed (file vs plan).
pub kind: LineViewerKind,
/// The file being viewed.
pub path: PathBuf,
/// Viewer items (source lines interleaved with comment annotations).
pub lines: Vec<PlanViewerItem>,
/// Raw source lines kept for rebuilding items when comments change.
source_lines: Vec<SourceLine>,
/// ListPane state for navigation + visual selection.
pub list_state: ListPaneState,
/// The element ID being modified (if editing an existing element).
pub element_id: Option<ElementId>,
/// Whether we're inside an undo group (to close/cancel on exit).
pub in_undo_group: bool,
/// Cached inner popup area from last render (for mouse hit-testing).
/// Excludes the divider + footer rows in plan modes, so it matches
/// the area the ListPane was rendered into — used for routing list
/// events to `ListPaneState::handle_mouse_event`.
pub last_popup_area: Option<Rect>,
/// Cached full modal area (inside the border, including the footer)
/// from the last render. Used by the click-outside-modal check so
/// clicks landing on the divider or empty space between footer
/// buttons don't accidentally close the modal.
pub last_modal_area: Option<Rect>,
/// Cached close button rect from last render (for mouse hit-testing).
pub close_button_area: Option<Rect>,
/// Whether the close button is hovered (for highlight).
pub close_hovered: bool,
/// Cached fullscreen toggle button rect from last render.
pub fullscreen_button_area: Option<Rect>,
/// Whether the fullscreen button is hovered.
pub fullscreen_hovered: bool,
/// Plan-specific state. `Some` only when `kind == PlanPreview`.
/// Keeps plan-only fields (buttons, approval, double-click) out of
/// the generic viewer.
pub plan: Option<PlanViewerExtras>,
/// Initial scroll range (0-based indices). Consumed on first prepare_layout
/// to center the range in the viewport.
initial_scroll_range: Option<Range<usize>>,
/// Optional title override. When set, the viewer title bar shows this
/// instead of the (potentially long) file path. Used for plan previews.
pub title_override: Option<String>,
/// Raw markdown content for rebuilding when the display width changes.
/// `None` for non-markdown viewers.
markdown_content: Option<String>,
/// The last `max_table_width` used to build markdown lines.
/// Compared against the current content width in `prepare_layout` to
/// trigger a rebuild when the viewer is resized.
last_table_width: Option<usize>,
/// Copy of comments last applied via `rebuild_with_comments`, so that
/// a width-triggered rebuild can re-interleave them automatically.
last_comments: Vec<crate::views::plan_approval_view::PlanComment>,
/// When `true`, the viewer uses the full overlay area instead of the
/// 75% centered popup. Toggled by Ctrl+F.
pub fullscreen: bool,
}
impl LineViewerState {
/// Open a file and create the viewer state.
///
/// Returns `None` if the file can't be read.
pub fn open(path: &Path, element_id: Option<ElementId>) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
let source_lines = build_source_lines(path, &content);
let lines = source_lines
.iter()
.cloned()
.map(|s| PlanViewerItem::Source(Box::new(s)))
.collect();
let config = ListPaneConfig {
follow_enabled: false,
wrap_toggle_enabled: false,
search_enabled: true,
copy_enabled: true,
show_selection_when_unfocused: true,
visual_select_enabled: true,
filter_enabled: false,
goto_line_enabled: true,
};
let list_state = ListPaneState::new_with_config(WrapMode::Wrap, false, config);
Some(Self {
kind: LineViewerKind::FilePreview,
path: path.to_path_buf(),
lines,
source_lines,
list_state,
element_id,
in_undo_group: false,
last_popup_area: None,
last_modal_area: None,
close_button_area: None,
close_hovered: false,
fullscreen_button_area: None,
fullscreen_hovered: false,
plan: None,
initial_scroll_range: None,
title_override: None,
markdown_content: None,
last_table_width: None,
last_comments: Vec::new(),
fullscreen: false,
})
}
/// Open a file and create the viewer with markdown rendering.
///
/// Same as `open()` but renders the content as rich markdown instead of
/// raw syntax-highlighted text. Source-line-based navigation is preserved.
pub fn open_markdown(path: &Path, element_id: Option<ElementId>) -> Option<Self> {
let content = std::fs::read_to_string(path).ok()?;
Self::open_markdown_content(path.to_path_buf(), content, element_id)
}
pub fn open_markdown_content(
path: impl Into<PathBuf>,
content: String,
element_id: Option<ElementId>,
) -> Option<Self> {
if content.trim().is_empty() {
return None;
}
let path = path.into();
// Defer the actual markdown render to the first `prepare_layout` call
// which knows the display width. `rebuild_markdown_for_width` will
// build source_lines with the correct `max_table_width`.
let source_lines = Vec::new();
let lines = Vec::new();
let config = ListPaneConfig {
follow_enabled: false,
wrap_toggle_enabled: false,
search_enabled: true,
copy_enabled: true,
show_selection_when_unfocused: true,
visual_select_enabled: true,
filter_enabled: false,
goto_line_enabled: true,
};
let list_state = ListPaneState::new_with_config(WrapMode::Wrap, false, config);
Some(Self {
kind: LineViewerKind::FilePreview,
path,
lines,
source_lines,
list_state,
element_id,
in_undo_group: false,
last_popup_area: None,
last_modal_area: None,
close_button_area: None,
close_hovered: false,
fullscreen_button_area: None,
fullscreen_hovered: false,
plan: None,
initial_scroll_range: None,
title_override: None,
markdown_content: Some(content),
last_table_width: None,
last_comments: Vec::new(),
fullscreen: false,
})
}
/// Set initial selection and scroll to a line range (1-based).
///
/// Enters visual mode with the range pre-selected and scrolls so the
/// range is visible (centered if possible).
pub fn set_initial_selection(&mut self, range: Range<usize>) {
// Convert 1-based line numbers to 0-based ListPane indices.
let start_idx = range.start.saturating_sub(1);
let end_idx = range.end.saturating_sub(1).min(self.lines.len());
if start_idx < self.lines.len() {
// Select the start line.
let start_id = self.lines[start_idx].stable_id();
self.list_state.select_by_id(start_id);
// Enter visual mode and extend to end line.
if end_idx > start_idx + 1 {
// Multi-line range: enter visual mode.
self.list_state.enter_visual_mode(&self.lines);
// Move selection to the end of the range.
let end_line_idx = (end_idx - 1).min(self.lines.len() - 1);
let end_id = self.lines[end_line_idx].stable_id();
self.list_state.select_by_id(end_id);
}
// Store the range for scroll centering on first render.
self.initial_scroll_range = Some(start_idx..end_idx);
}
}
/// Rebuild markdown items when the available content width changes.
///
/// Recomputes `max_table_width` from the ListPane's content width
/// (total width minus line-number prefix), then re-renders markdown
/// with constrained tables so box-drawing borders aren't word-wrapped.
fn rebuild_markdown_for_width(&mut self, width: u16) {
let Some(ref content) = self.markdown_content else {
return;
};
let prefix_width = digit_count(source_line_count(content).max(1)) + 1;
let scrollbar_width = SCROLLBAR_TOTAL_COLS as usize; // gap + track
let content_width = (width as usize)
.saturating_sub(prefix_width)
.saturating_sub(scrollbar_width);
if self.last_table_width == Some(content_width) {
return;
}
self.last_table_width = Some(content_width);
self.source_lines = build_markdown_lines(content, Some(content_width));
if self.last_comments.is_empty() {
self.lines = self
.source_lines
.iter()
.cloned()
.map(|s| PlanViewerItem::Source(Box::new(s)))
.collect();
} else {
let comments = self.last_comments.clone();
self.interleave_comments(&comments);
}
}
#[cfg(test)]
pub(crate) fn markdown_content_for_test(&self) -> Option<&str> {
self.markdown_content.as_deref()
}
/// Returns the raw markdown content for feedback formatting.
pub fn markdown_content_for_feedback(&self) -> Option<String> {
self.markdown_content.clone()
}
/// Mutable access to plan-specific extras, initializing if needed.
pub fn plan_mut(&mut self) -> &mut PlanViewerExtras {
self.plan.get_or_insert_with(PlanViewerExtras::default)
}
/// Read-only access to plan-specific extras.
pub fn plan_ref(&self) -> Option<&PlanViewerExtras> {
self.plan.as_ref()
}
pub fn feedback_active(&self) -> bool {
self.plan.as_ref().is_some_and(|p| p.feedback_active)
}
/// Whether the plan modal should render the action-button footer.
/// True for both modes: plan-approval (q/c/s|a) and casual
/// (c/s — quit via the close-X button instead of a footer button).
pub fn show_footer(&self) -> bool {
self.plan
.as_ref()
.is_some_and(|p| p.feedback_active || p.show_action_buttons)
}
pub fn source_line_at_screen_row(&self, row: u16, content_area: Rect) -> Option<usize> {
if row < content_area.y || row >= content_area.y + content_area.height {
return None;
}
let ry = (row - content_area.y) as usize;
let vy = self.list_state.scroll_offset() + ry;
let vi = self.list_state.layout().item_at_y(vy)?;
let pi = self.list_state.to_physical(vi);
self.lines.get(pi)?.line_number()
}
/// Prepare the layout for rendering (must be called each frame).
pub fn prepare_layout(&mut self, width: u16, height: u16) {
self.rebuild_markdown_for_width(width);
self.list_state.prepare_layout(&self.lines, width, height);
// On the first render with an initial range, center the range
// in the viewport. Consumed once so subsequent navigation is normal.
if let Some(range) = self.initial_scroll_range.take() {
let vp = height as usize;
let total = self.lines.len();
let pad = 3usize; // inner padding (lines of context above/below)
if total <= vp {
// Entire file fits — no scrolling needed.
} else {
let range_len = range.end.saturating_sub(range.start);
let offset = if range_len + pad * 2 <= vp {
// Range fits with padding — center it.
let center = range.start + range_len / 2;
center.saturating_sub(vp / 2)
} else {
// Range larger than viewport — put start near top with padding.
range.start.saturating_sub(pad)
};
// Clamp to valid range.
let max_offset = total.saturating_sub(vp);
self.list_state.set_scroll_offset(offset.min(max_offset));
}
}
}
/// Rebuild `self.lines` from source lines + comments.
///
/// Comments are inserted after the last source line in their range.
/// Item IDs for comments use a high base offset to avoid colliding
/// with source line IDs.
pub fn rebuild_with_comments(
&mut self,
comments: &[crate::views::plan_approval_view::PlanComment],
) {
self.last_comments = comments.to_vec();
self.interleave_comments(comments);
// Item count/content changed — force the list pane to recompute
// wrapping heights on the next render frame.
self.list_state.invalidate_layout();
}
/// Interleave source lines with comments without updating `last_comments`.
fn interleave_comments(&mut self, comments: &[crate::views::plan_approval_view::PlanComment]) {
let max_digits = digit_count(self.source_lines.len().max(1));
let mut sorted: Vec<_> = comments.iter().collect();
sorted.sort_by_key(|c| c.line_range.end);
let mut commented_lines = std::collections::HashSet::new();
for c in comments {
for ln in c.line_range.clone() {
commented_lines.insert(ln);
}
}
let mut items: Vec<PlanViewerItem> = Vec::new();
let mut comment_idx = 0;
let comment_id_base: u64 = 1_000_000;
for src in &self.source_lines {
let ln = src.line_number;
let mut src = src.clone();
src.commented = commented_lines.contains(&ln);
items.push(PlanViewerItem::Source(Box::new(src)));
while comment_idx < sorted.len() && sorted[comment_idx].line_range.end == ln + 1 {
let c = sorted[comment_idx];
let item_id = comment_id_base + c.id;
items.push(PlanViewerItem::Comment(CommentLine::new(
c.id,
item_id,
&c.line_range,
c.text.clone(),
max_digits,
)));
comment_idx += 1;
}
}
for c in &sorted[comment_idx..] {
let item_id = comment_id_base + c.id;
items.push(PlanViewerItem::Comment(CommentLine::new(
c.id,
item_id,
&c.line_range,
c.text.clone(),
max_digits,
)));
}
self.lines = items;
}
/// Get the selected line range (1-based, inclusive).
///
/// Returns a single line if no visual selection, or the visual range.
pub fn selected_line_range(&self) -> Option<Range<usize>> {
if let Some(range) = self.list_state.multi_range() {
// Scan forward from start to find first source line.
let start = (range.start..range.end).find_map(|i| self.lines.get(i)?.line_number())?;
// Scan backward from end to find last source line.
let end = (range.start..range.end)
.rev()
.find_map(|i| self.lines.get(i)?.line_number())?;
Some(start..end + 1)
} else {
let idx = self.list_state.selected_index()?;
let ln = self.lines.get(idx)?.line_number()?;
Some(ln..ln + 1)
}
}
/// Format the line range as a suffix string (e.g., `:10` or `:10-12`).
pub fn line_range_suffix(&self) -> Option<String> {
let range = self.selected_line_range()?;
if range.len() == 1 {
Some(format!(":{}", range.start))
} else {
Some(format!(":{}-{}", range.start, range.end - 1))
}
}
/// ListPane style matching the pager's visual language.
pub fn list_pane_style() -> ListPaneStyle {
let theme = Theme::current();
ListPaneStyle {
input_bar_bg: theme.bg_dark,
input_bar_prompt_fg: theme.command,
scrollbar_bg: theme.bg_dark,
scrollbar_fg: theme.gray_dim,
uniform_visual_bg: true,
..ListPaneStyle::default()
}
}
}
// ── Syntax highlighting ─────────────────────────────────────────────────
/// Build syntax-highlighted source lines from file content.
fn build_source_lines(path: &Path, content: &str) -> Vec<SourceLine> {
let syntect = get_syntect();
let mut highlighter = syntect.highlight_lines_by_file_path(path);
// Split preserving all lines including trailing empty ones.
// `split('\n')` keeps trailing empty strings unlike `.lines()`.
let raw_lines: Vec<&str> = content.split('\n').collect();
// If the file ends with a newline, remove the trailing empty split artifact.
let line_count = if content.ends_with('\n') && raw_lines.last() == Some(&"") {
raw_lines.len() - 1
} else {
raw_lines.len()
};
let max_digits = digit_count(line_count.max(1));
raw_lines
.iter()
.take(line_count)
.enumerate()
.map(|(i, text)| {
let line_number = i + 1;
let styled_line = if text.is_empty() {
// Empty line — still needs a Line (for line number prefix).
Line::from(" ".to_owned())
} else if let Some(ref mut hl) = highlighter {
highlight_to_ratatui_line(hl, text, &syntect.syntax_set)
} else {
Line::from((*text).to_owned())
};
SourceLine::new(line_number, styled_line, (*text).to_owned(), max_digits)
})
.collect()
}
/// Count source lines in content, matching `str::split('\n')` semantics
/// with trailing-newline handling.
fn source_line_count(content: &str) -> usize {
let count = content.split('\n').count();
if content.ends_with('\n') {
count - 1
} else {
count
}
}
/// Build markdown-rendered source lines from file content.
///
/// Uses `MarkdownContent` to render the full document, then groups rendered
/// lines by source line using `line_source_map`. Each source line becomes
/// one `SourceLine` item that may span multiple visual lines (e.g., a table
/// block renders as border + header + separator + data + border).
fn build_markdown_lines(content: &str, max_table_width: Option<usize>) -> Vec<SourceLine> {
let md = MarkdownContent::new_source_faithful(content, max_table_width);
let pre_wrap = md.pre_wrap_lines();
let source_map = md.line_source_map();
// Background colors come from each line's style (set by the renderer
// for code blocks etc.). pre_wrap_lines() returns owned Lines that
// carry their style including bg.
let line_bgs: Vec<Option<Color>> = pre_wrap.iter().map(|line| line.style.bg).collect();
// Split source text into raw lines for plain_text / search.
let raw_lines: Vec<&str> = content.split('\n').collect();
let slc = source_line_count(content);
let max_digits = digit_count(slc.max(1));
// Group rendered lines by source line number.
// source_map is indexed by rendered-line index, value is 0-based source line.
let mut groups: Vec<(usize, Vec<Line<'static>>, Vec<Option<Color>>)> = Vec::new();
for (rendered_idx, rendered_line) in pre_wrap.into_iter().enumerate() {
let src_line = source_map.get(rendered_idx).copied().unwrap_or(0);
let bg = line_bgs.get(rendered_idx).copied().flatten();
if let Some(last) = groups.last_mut()
&& last.0 == src_line
{
last.1.push(rendered_line);
last.2.push(bg);
continue;
}
groups.push((src_line, vec![rendered_line], vec![bg]));
}
let mut source_lines = Vec::new();
let mut next_item_id = 0u64;
let mut next_blank_src = 0usize;
for (src_line_0based, rendered_lines, rendered_bgs) in groups {
for blank_src in next_blank_src..src_line_0based.min(slc) {
if raw_lines
.get(blank_src)
.is_some_and(|line| line.trim().is_empty())
{
source_lines.push(SourceLine::new_markdown(
next_item_id,
blank_src + 1,
vec![Line::default()],
vec![None],
raw_lines.get(blank_src).unwrap_or(&"").to_string(),
max_digits,
));
next_item_id += 1;
}
}
source_lines.push(SourceLine::new_markdown(
next_item_id,
src_line_0based + 1,
rendered_lines,
rendered_bgs,
raw_lines.get(src_line_0based).unwrap_or(&"").to_string(),
max_digits,
));
next_item_id += 1;
next_blank_src = next_blank_src.max(src_line_0based.saturating_add(1));
}
for blank_src in next_blank_src..slc {
if raw_lines
.get(blank_src)
.is_some_and(|line| line.trim().is_empty())
{
source_lines.push(SourceLine::new_markdown(
next_item_id,
blank_src + 1,
vec![Line::default()],
vec![None],
raw_lines.get(blank_src).unwrap_or(&"").to_string(),
max_digits,
));
next_item_id += 1;
}
}
source_lines
}
/// Convert syntect highlighting output to a ratatui Line.
fn highlight_to_ratatui_line(
hl: &mut HighlightLines<'_>,
text: &str,
syntax_set: &syntect::parsing::SyntaxSet,
) -> Line<'static> {
let highlighted = match hl.highlight_line(text, syntax_set) {
Ok(h) => h,
Err(_) => return Line::from(text.to_owned()),
};
let spans: Vec<Span<'static>> = highlighted
.into_iter()
.map(|(style, content)| {
let fg = syntect_to_ratatui_color(style.foreground);
Span::styled(content.to_owned(), Style::default().fg(fg))
})
.collect();
Line::from(spans)
}
/// Convert a syntect RGBA color to a ratatui Color.
///
/// Quantizes the RGB value to the terminal's supported color level.
fn syntect_to_ratatui_color(c: syntect::highlighting::Color) -> ratatui::style::Color {
crate::theme::quantize(ratatui::style::Color::Rgb(c.r, c.g, c.b))
}
/// Count digits in a number (for line number padding).
fn digit_count(n: usize) -> usize {
if n == 0 {
1
} else {
((n as f64).log10().floor() as usize) + 1
}
}
// ── Rendering helpers ───────────────────────────────────────────────────
/// Build a single review-footer shortcut button styled to match the
/// shortcut hints in `modal_window::render_modal_shortcuts`:
/// bold key in the primary text color + dim label, with a
/// hover-highlighted background.
fn build_shortcut_button<'a>(
key: char,
rest: &str,
hovered: bool,
theme: &crate::theme::Theme,
) -> Vec<Span<'a>> {
let bg = if hovered {
theme.bg_highlight
} else {
theme.bg_base
};
let key_style = Style::default()
.fg(theme.text_primary)
.bg(bg)
.add_modifier(Modifier::BOLD);
let label_style = Style::default().fg(theme.gray).bg(bg);
vec![
Span::styled(key.to_string(), key_style),
Span::styled(format!(" {rest}"), label_style),
]
}
/// Render the line viewer popup.
///
/// In normal mode, draws a 75% centered panel with dimmed background
/// (modifiers reset). In fullscreen mode (`viewer.fullscreen`), fills
/// the entire overlay area without dimming. Renders the ListPane
/// inside the panel with syntax-highlighted lines.
pub fn render_line_viewer(
buf: &mut Buffer,
full_area: Rect,
viewer: &mut LineViewerState,
cwd: &Path,
theme: &Theme,
comment_count: usize,
) {
// Compute popup area. In enlarge (fullscreen) mode the popup
// nearly fills the overlay, but leaves 1 row of top padding and
// 2 cols of side padding so it doesn't crowd the screen edges
// (the caller already excludes the prompt + turn_status from
// `full_area`). In normal mode it sits in a 75% centered popup.
let (popup_area, should_dim) = if viewer.fullscreen {
const TOP_PAD: u16 = 1;
const SIDE_PAD: u16 = 2;
let pad_w = SIDE_PAD.saturating_mul(2);
let popup_x = full_area.x + SIDE_PAD.min(full_area.width);
let popup_y = full_area.y + TOP_PAD.min(full_area.height);
let popup_w = full_area.width.saturating_sub(pad_w);
let popup_h = full_area.height.saturating_sub(TOP_PAD);
(Rect::new(popup_x, popup_y, popup_w, popup_h), false)
} else {
let popup_width = (full_area.width as f32 * 0.75) as u16;
let popup_height = (full_area.height as f32 * 0.75) as u16;
let popup_x = full_area.x + (full_area.width.saturating_sub(popup_width)) / 2;
let popup_y = full_area.y + (full_area.height.saturating_sub(popup_height)) / 2;
(Rect::new(popup_x, popup_y, popup_width, popup_height), true)
};
// Plan modes (both review and casual) reserve 2 extra rows inside
// the frame for the divider + action-button footer, so they need
// a slightly taller minimum than ordinary file previews.
let min_height: u16 = if viewer.show_footer() { 7 } else { 5 };
if popup_area.width < 10 || popup_area.height < min_height {
viewer.last_popup_area = None;
viewer.last_modal_area = None;
return;
}
// 1. Dim the entire screen behind the popup (skip when fullscreen).
if should_dim {
dim_area(buf, full_area, theme.bg_base, 0.5);
}
// 2. Clear the popup area.
ratatui::widgets::Clear.render(popup_area, buf);
buf.set_style(
popup_area,
Style::default().fg(theme.text_primary).bg(theme.bg_base),
);
// 3. Draw border.
let border = ratatui::widgets::Block::default()
.borders(ratatui::widgets::Borders::ALL)
.border_type(ratatui::widgets::BorderType::Rounded)
.border_style(Style::default().fg(theme.gray_dim))
.style(Style::default().bg(theme.bg_base));
let inner = border.inner(popup_area);
border.render(popup_area, buf);
// Plan modes reserve 2 rows at the bottom of `inner` for the
// divider + action-button row (rendered in step 8 below). Compute
// the actual content_area now so `prepare_layout` sees the true
// viewport height — passing the larger `inner.height` would make
// `ListPaneState`'s auto-scroll/paging math off by `footer_rows`
// (the selection can hide behind the footer; Ctrl-D/Ctrl-U jump
// too far; initial-scroll-to-range centering is mis-sized).
let footer_rows: u16 = if viewer.show_footer() { 2 } else { 0 };
let content_area = Rect {
x: inner.x,
y: inner.y,
width: inner.width,
height: inner.height.saturating_sub(footer_rows),
};
// 4. Prepare layout (resolves selection index for title + rendering).
viewer.prepare_layout(content_area.width, content_area.height);
// 5. Title bar: styled file path + line range.
// Only show line range when visual selection is active.
// When title_override is set (e.g. plan preview), use that instead
// of the full file path to avoid overflow.
if inner.width > 4 {
let rel_path = viewer.path.strip_prefix(cwd).unwrap_or(&viewer.path);
let rel_path_str = viewer
.title_override
.as_deref()
.unwrap_or_else(|| rel_path.to_str().unwrap_or(""))
.to_string();
let line_range = if viewer.list_state.visual_mode {
viewer.line_range_suffix().map(|s| {
// Strip leading ':' — styled_file_ref adds its own.
s.strip_prefix(':').unwrap_or(&s).to_owned()
})
} else {
None
};
// Build styled title with shared helper.
let mut title = super::styled_file_ref(
&rel_path_str,
line_range.as_deref(),
theme,
false, // no @ prefix in viewer title
);
// Add bg to all spans (title sits on the border).
for span in &mut title.spans {
span.style = span.style.bg(theme.bg_base);
}
// Wrap with `─ ... ─` decorations to match other modals
// (see modal_window.rs:341-346) and left-align flush with the
// top-left corner.
let deco = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
title.spans.insert(0, Span::styled("\u{2500} ", deco));
title.spans.push(Span::styled(" \u{2500}", deco));
let title_width = title.width() as u16;
let title_x = popup_area.x + 1;
let max_title_width = popup_area.width.saturating_sub(2);
buf.set_line(
title_x,
popup_area.y,
&title,
title_width.min(max_title_width),
);
}
// 6. Action buttons on the top border, right-aligned.
// Layout: ... [↗][✗] (rightmost buttons first; the two abut
// flush — see the spacing notes on the close/fullscreen labels
// below).
// The close [✗] is omitted in plan-review (feedback) mode because
// the modal is not user-closeable in that state — clicking it
// would be a no-op (see the close-button branch of
// `handle_line_viewer_mouse` in agent_view.rs).
let mut right_edge = popup_area.x + popup_area.width - 1;
if !viewer.feedback_active() {
let close_text = crate::glyphs::ballot_x(); // ✗ (ASCII on legacy ConHost)
// Label is `[✗] ` (trailing space, no leading space). The
// fullscreen button's label has no trailing space when the
// close is visible, so the two buttons abut flush as `[↗][✗]`,
// tucked under the top-right corner with one space inside the
// frame on each side: ` [↗][✗] `.
let close_w: u16 = 4; // "[✗] "
if popup_area.width > close_w + 2 {
let close_x = right_edge - close_w;
let close_style = if viewer.close_hovered {
Style::default()
.fg(theme.text_primary)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.gray).bg(theme.bg_base)
};
let close_span = Span::styled(format!("[{close_text}] "), close_style);
buf.set_span(close_x, popup_area.y, &close_span, close_w);
viewer.close_button_area = Some(Rect::new(close_x, popup_area.y, close_w, 1));
right_edge = close_x;
} else {
viewer.close_button_area = None;
}
} else {
viewer.close_button_area = None;
}
// Fullscreen toggle button. The icon stays constant regardless of
// current state — the button is a toggle, not a status indicator.
//
// Spacing: when the close button is rendered (casual mode) the
// fullscreen drops its trailing space so the two sit flush as
// `[↗][✗]`. When the close is hidden (plan-review mode) the
// fullscreen keeps its trailing space so it doesn't crowd the
// corner `╮`.
let fs_icon = crate::glyphs::enlarge(); // ↗ (ASCII on legacy ConHost)
let close_visible = viewer.close_button_area.is_some();
let (fs_label, fs_w): (String, u16) = if close_visible {
(format!(" [{fs_icon}]"), 4)
} else {
(format!(" [{fs_icon}] "), 5)
};
if right_edge > popup_area.x + fs_w + 2 {
let fs_x = right_edge - fs_w;
let fs_style = if viewer.fullscreen_hovered {
Style::default()
.fg(theme.text_primary)
.bg(theme.bg_base)
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(theme.gray).bg(theme.bg_base)
};
let fs_span = Span::styled(fs_label, fs_style);
buf.set_span(fs_x, popup_area.y, &fs_span, fs_w);
viewer.fullscreen_button_area = Some(Rect::new(fs_x, popup_area.y, fs_w, 1));
} else {
viewer.fullscreen_button_area = None;
}
// The legacy top-border "send" button is gone — both plan-approval
// and casual modes now render the send action in the modal footer
// alongside the other shortcut buttons. Clear stale hit-rects so
// mouse handlers don't act on positions from a previous render.
if let Some(plan) = viewer.plan.as_mut() {
plan.send_button_area = None;
plan.approve_button_area = None;
plan.abandon_button_area = None;
}
// 7. Render ListPane.
// Plan modes (review + casual) reserve 2 rows at the bottom of
// `inner` for a horizontal divider plus the action-button row.
// The divider sits at `inner.bottom() - 2` and the buttons at
// `inner.bottom() - 1`, both inside the modal frame.
//
// `footer_rows` / `content_area` were computed above (just
// after `inner`) so `prepare_layout` could see the true
// viewport height. Reuse them here.
let style = LineViewerState::list_pane_style();
let pane = ListPane::new(&viewer.lines).focused(true).style(style);
StatefulWidget::render(pane, content_area, buf, &mut viewer.list_state);
// Cache the list-rendered area (for ListPane mouse dispatch) and
// the full modal area inside the border (for the click-outside
// check that decides whether to close the modal).
viewer.last_popup_area = Some(content_area);
viewer.last_modal_area = Some(inner);
// 7b. Line range highlight — active drag or commenting range.
if let Some(plan) = viewer.plan_ref() {
let highlight_range =
if let (Some(start), Some(end)) = (plan.gutter_drag_start, plan.gutter_drag_end) {
if start != end {
Some((start.min(end), start.max(end)))
} else {
None
}
} else {
plan.active_commenting_range
.as_ref()
.map(|r| (r.start, r.end.saturating_sub(1)))
};
if let Some((lo, hi)) = highlight_range {
let blend_bg =
crate::render::color::blend_color(theme.bg_base, theme.accent_plan, 0.15)
.unwrap_or(theme.accent_plan);
// Stop the highlight one column before the scrollbar so
// the gap + track stay readable instead of being tinted
// by the comment-range overlay.
let highlight_width = content_area.width.saturating_sub(SCROLLBAR_TOTAL_COLS);
for row in content_area.y..content_area.y + content_area.height {
if let Some(ln) = viewer.source_line_at_screen_row(row, content_area)
&& ln >= lo
&& ln <= hi
{
let row_rect = Rect::new(content_area.x, row, highlight_width, 1);
buf.set_style(row_rect, Style::default().bg(blend_bg));
}
}
}
}
// 8. Action buttons inside the modal footer (centered), for both
// plan-approval and casual plan-preview modes. A full-width `─`
// divider separates the button row from the content above
// (matches modal_window.rs's tab divider style).
//
// Buttons use the same `key bold + label dim` treatment as
// `render_modal_shortcuts`, sit in a single row separated by
// ` | `, centered within the modal frame.
//
// - Plan-approval: q quit | c comment | s send / a approve
// - Casual preview: c comment | s send (no `q` —
// the close-X button handles closing in casual mode)
if viewer.show_footer() && inner.height >= 2 {
let div_y = inner.y + inner.height - 2;
let div_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
let line: String = std::iter::repeat_n('\u{2500}', inner.width as usize).collect();
buf.set_string(inner.x, div_y, &line, div_style);
let bottom_y = inner.y + inner.height - 1;
let abandon_hovered = viewer.plan_ref().is_some_and(|p| p.abandon_hovered);
let comment_hovered = viewer.plan_ref().is_some_and(|p| p.comment_hovered);
let approve_hovered = viewer.plan_ref().is_some_and(|p| p.approve_hovered);
let is_approval = viewer.feedback_active();
let comment_spans = build_shortcut_button('c', "comment", comment_hovered, theme);
let comment_w: u16 = comment_spans.iter().map(|s| s.width() as u16).sum();
// In approval mode, always show `a approve`. When there are
// pending review comments, also show `s revise` (request changes).
// In approval mode, show `a approve` (or `a approve w/ comments`
// when inline comments are pending). In casual mode, show `s send`
// only when comments exist.
let (_action_label, action_w, action_spans): (&str, u16, Option<Vec<Span>>) = if is_approval
{
let label = if comment_count > 0 {
"approve w/ comments"
} else {
"approve"
};
let spans = build_shortcut_button('a', label, approve_hovered, theme);
let w: u16 = spans.iter().map(|s| s.width() as u16).sum();
(label, w, Some(spans))
} else if comment_count > 0 {
let spans = build_shortcut_button('s', "send", approve_hovered, theme);
let w: u16 = spans.iter().map(|s| s.width() as u16).sum();
("send", w, Some(spans))
} else {
("", 0, None)
};
// `s revise` button — always visible in approval mode so the
// user can request changes (switches to prompt for revision notes).
let (revise_w, revise_spans): (u16, Option<Vec<Span>>) = if is_approval {
let send_hovered = viewer.plan_ref().is_some_and(|p| p.send_hovered);
let spans = build_shortcut_button('s', "request changes", send_hovered, theme);
let w: u16 = spans.iter().map(|s| s.width() as u16).sum();
(w, Some(spans))
} else {
(0, None)
};
// Quit button only renders in approval mode (casual closes via X).
let quit_spans = if is_approval {
let s = build_shortcut_button('q', "quit plan", abandon_hovered, theme);
let w: u16 = s.iter().map(|s| s.width() as u16).sum();
Some((s, w))
} else {
None
};
// Pending-comment badge rendered after the `c comment` button
// as ` N ●` in `accent_plan`. Shown whenever comments exist.
use unicode_width::UnicodeWidthStr;
let badge_text: String = if comment_count > 0 {
format!(" {comment_count} {}", crate::glyphs::filled_dot())
} else {
String::new()
};
let badge_w: u16 = badge_text.width() as u16;
let badge_style = Style::default().fg(theme.accent_plan).bg(theme.bg_base);
let separator = " | ";
let sep_w: u16 = 5; // separator is fixed-width ASCII; matches modal_window.rs:565
let sep_style = Style::default().fg(theme.gray_dim).bg(theme.bg_base);
// Total width: [action] + (sep + revise)? + sep + comment[badge?] + (sep + quit)?
let mut total_w: u16 = 0;
if action_w > 0 {
total_w = total_w.saturating_add(action_w).saturating_add(sep_w);
}
if revise_w > 0 {
total_w = total_w.saturating_add(revise_w).saturating_add(sep_w);
}
total_w = total_w.saturating_add(comment_w).saturating_add(badge_w);
if let Some((_, w)) = &quit_spans {
total_w = total_w.saturating_add(sep_w).saturating_add(*w);
}
if total_w <= inner.width {
let mut x = inner.x + (inner.width - total_w) / 2;
// Action button (approve / send) — left-most.
if let Some(spans) = &action_spans {
let approve_x = x;
for span in spans {
let w = span.width() as u16;
buf.set_span(x, bottom_y, span, w);
x += w;
}
viewer.plan_mut().approve_button_area =
Some(Rect::new(approve_x, bottom_y, action_w, 1));
buf.set_string(x, bottom_y, separator, sep_style);
x += sep_w;
} else {
viewer.plan_mut().approve_button_area = None;
}
// Revise button — approval mode with comments.
if let Some(spans) = &revise_spans {
let revise_x = x;
for span in spans {
let w = span.width() as u16;
buf.set_span(x, bottom_y, span, w);
x += w;
}
viewer.plan_mut().send_button_area =
Some(Rect::new(revise_x, bottom_y, revise_w, 1));
buf.set_string(x, bottom_y, separator, sep_style);
x += sep_w;
} else {
viewer.plan_mut().send_button_area = None;
}
// Comment button — always present in both modes.
let comment_x = x;
for span in &comment_spans {
let w = span.width() as u16;
buf.set_span(x, bottom_y, span, w);
x += w;
}
viewer.plan_mut().comment_button_area =
Some(Rect::new(comment_x, bottom_y, comment_w, 1));
if badge_w > 0 {
buf.set_string(x, bottom_y, &badge_text, badge_style);
x += badge_w;
}
// Quit button — approval mode only.
if let Some((spans, w)) = quit_spans {
buf.set_string(x, bottom_y, separator, sep_style);
x += sep_w;
let quit_x = x;
for span in &spans {
let sw = span.width() as u16;
buf.set_span(x, bottom_y, span, sw);
x += sw;
}
viewer.plan_mut().abandon_button_area = Some(Rect::new(quit_x, bottom_y, w, 1));
} else {
viewer.plan_mut().abandon_button_area = None;
}
} else {
// Footer too narrow — disable hit-tests so stale rects
// from a previous render don't fire.
let plan = viewer.plan_mut();
plan.approve_button_area = None;
plan.comment_button_area = None;
plan.abandon_button_area = None;
}
}
}
pub use crate::render::color::dim_area;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_markdown_content_uses_in_memory_content() {
let viewer = LineViewerState::open_markdown_content(
"plan.md",
"# Plan\n\n- Do the thing".to_owned(),
None,
)
.expect("markdown content should open");
assert_eq!(viewer.path, PathBuf::from("plan.md"));
assert_eq!(
viewer.markdown_content.as_deref(),
Some("# Plan\n\n- Do the thing")
);
}
#[test]
fn markdown_content_for_test_returns_in_memory_content() {
let viewer =
LineViewerState::open_markdown_content("plan.md", "# Latest Plan".to_owned(), None)
.expect("markdown content should open");
assert_eq!(viewer.markdown_content_for_test(), Some("# Latest Plan"));
}
#[test]
fn open_markdown_content_rejects_blank_content() {
assert!(
LineViewerState::open_markdown_content("plan.md", " \n".to_owned(), None).is_none()
);
}
fn line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect()
}
fn source_line(item: &PlanViewerItem) -> &SourceLine {
match item {
PlanViewerItem::Source(source) => source,
PlanViewerItem::Comment(_) => panic!("expected source line"),
}
}
fn row_text(buf: &Buffer, y: u16) -> String {
let area = *buf.area();
let mut row = String::new();
for x in area.left()..area.right() {
row.push_str(buf[(x, y)].symbol());
}
row
}
#[test]
fn build_markdown_lines_preserves_blank_source_lines() {
let lines = build_markdown_lines("# Plan\n\n- First\n\n- Second", Some(80));
let numbered_rows: Vec<(usize, Vec<String>)> = lines
.iter()
.map(|line| {
(
line.line_number,
line.rendered_lines.iter().map(line_text).collect(),
)
})
.collect();
assert_eq!(
numbered_rows,
vec![
(1, vec!["Plan".to_owned()]),
(2, vec![String::new()]),
(3, vec!["• First".to_owned()]),
(4, vec![String::new()]),
(5, vec!["• Second".to_owned()]),
]
);
}
#[test]
fn markdown_source_blank_line_renders_as_numbered_empty_row() {
let lines = build_markdown_lines("# Plan\n\n- First", Some(80));
let blank = &lines[1];
let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
blank.render(Rect::new(0, 0, 20, 1), &mut buf, false, true);
assert_eq!(blank.line_number, 2);
assert_eq!(row_text(&buf, 0), "2 ");
}
#[test]
fn markdown_viewer_selection_uses_source_line_numbers_with_blank_rows() {
let mut viewer = LineViewerState::open_markdown_content(
"plan.md",
"# Plan\n\n- First\n\n- Second".to_owned(),
None,
)
.expect("markdown content should open");
viewer.prepare_layout(80, 20);
let ids_by_line: Vec<(usize, u64)> = viewer
.lines
.iter()
.map(|item| {
let source = source_line(item);
(source.line_number, source.item_id)
})
.collect();
assert_eq!(
ids_by_line
.iter()
.map(|(line, _)| *line)
.collect::<Vec<_>>(),
vec![1, 2, 3, 4, 5]
);
let line_4_id = ids_by_line
.iter()
.find_map(|(line, id)| (*line == 4).then_some(*id))
.expect("blank source line should have its own row");
viewer.list_state.select_by_id(line_4_id);
viewer.prepare_layout(80, 20);
assert_eq!(viewer.selected_line_range(), Some(4..5));
assert_eq!(viewer.line_range_suffix(), Some(":4".to_owned()));
assert_eq!(source_line(&viewer.lines[3]).plain_text, "");
}
#[test]
fn markdown_viewer_preserves_soft_break_collapsed_lines() {
// Repro: consecutive non-blank lines (a poem) form one
// CommonMark paragraph. Source-faithful rendering must keep each line
// on its own numbered row instead of collapsing to one paragraph.
let mut viewer = LineViewerState::open_markdown_content(
"plan.md",
"Line one,\nLine two,\nLine three.".to_owned(),
None,
)
.expect("markdown content should open");
viewer.prepare_layout(80, 20);
let rows: Vec<(usize, String)> = viewer
.lines
.iter()
.map(|item| {
let s = source_line(item);
(s.line_number, line_text(&s.rendered_lines[0]))
})
.collect();
assert_eq!(
rows,
vec![
(1, "Line one,".to_owned()),
(2, "Line two,".to_owned()),
(3, "Line three.".to_owned()),
]
);
}
#[test]
fn markdown_viewer_comment_range_maps_full_soft_break_paragraph() {
// Commenting round-trip: selecting all rows of a soft-break paragraph
// must map back to the full file line range so the agent inspects the
// correct lines. Pre-fix this collapsed to a single line number.
let mut viewer = LineViewerState::open_markdown_content(
"plan.md",
"Line one,\nLine two,\nLine three.".to_owned(),
None,
)
.expect("markdown content should open");
viewer.prepare_layout(80, 20);
viewer.set_initial_selection(1..4);
viewer.prepare_layout(80, 20);
assert_eq!(viewer.selected_line_range(), Some(1..4));
assert_eq!(viewer.line_range_suffix(), Some(":1-3".to_owned()));
}
}