chore: commit all current changes

This commit is contained in:
James Peter
2025-08-29 11:44:16 +10:00
parent d060605a7b
commit 0704775454
5 changed files with 435 additions and 202 deletions
+83 -22
View File
@@ -2849,24 +2849,83 @@ async fn handle_web_fetch(
// Helper: remove obvious noisy blocks before markdown conversion.
fn strip_noisy_tags(mut html: String) -> String {
// Basic, case-insensitive removal of <script>, <style>, and <noscript> blocks.
// Avoids dumping challenge JS into markdown on blocked pages.
let patterns = [("script", "</script>"), ("style", "</style>"), ("noscript", "</noscript>")];
for (open, close) in patterns {
// Iterate until none remain (with a safety cap on iterations)
// Remove <script>, <style>, and <noscript> blocks with a simple
// ASCII case-insensitive scan that preserves UTF-8 boundaries.
// This avoids allocating lowercase copies and accidentally using
// indices from a different string representation.
fn eq_ascii_ci(a: u8, b: u8) -> bool {
a.to_ascii_lowercase() == b.to_ascii_lowercase()
}
fn starts_with_tag_ci(bytes: &[u8], tag: &[u8]) -> bool {
if bytes.len() < tag.len() { return false; }
for i in 0..tag.len() {
if !eq_ascii_ci(bytes[i], tag[i]) { return false; }
}
true
}
// Find the next opening tag like "<script" (allowing whitespace after '<').
fn find_open_tag_ci(s: &str, tag: &str, from: usize) -> Option<usize> {
let bytes = s.as_bytes();
let tag_bytes = tag.as_bytes();
let mut i = from;
while i + 1 < bytes.len() {
if bytes[i] == b'<' {
let mut j = i + 1;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'\n' || bytes[j] == b'\r') {
j += 1;
}
if j < bytes.len() && starts_with_tag_ci(&bytes[j..], tag_bytes) {
return Some(i);
}
}
i += 1;
}
None
}
// Find the corresponding closing tag like "</script>" starting at or after `from`.
// Returns the byte index just after the closing '>' if found.
fn find_close_after_ci(s: &str, tag: &str, from: usize) -> Option<usize> {
let bytes = s.as_bytes();
let tag_bytes = tag.as_bytes();
let mut i = from;
while i + 2 < bytes.len() { // need at least '<' '/' and one tag byte
if bytes[i] == b'<' && i + 1 < bytes.len() && bytes[i + 1] == b'/' {
let mut j = i + 2;
// Optional whitespace before tag name
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t' || bytes[j] == b'\n' || bytes[j] == b'\r') {
j += 1;
}
if starts_with_tag_ci(&bytes[j..], tag_bytes) {
// Advance past tag name
j += tag_bytes.len();
// Skip optional whitespace until '>'
while j < bytes.len() && bytes[j] != b'>' {
j += 1;
}
if j < bytes.len() && bytes[j] == b'>' {
return Some(j + 1);
}
return None; // No closing '>'
}
}
i += 1;
}
None
}
let tags = ["script", "style", "noscript"];
for tag in tags.iter() {
let mut guard = 0;
loop {
if guard > 64 { break; }
let start = html.to_lowercase().find(&format!("<{open}"));
let Some(s) = start else { break; };
let end_search_from = s + 1;
let lower = html.to_lowercase();
let end = lower[end_search_from..].find(&close).map(|i| end_search_from + i + close.len());
if let Some(e) = end {
html.replace_range(s..e, "");
let Some(start) = find_open_tag_ci(&html, tag, 0) else { break; };
let search_from = start + 1; // after '<'
if let Some(end) = find_close_after_ci(&html, tag, search_from) {
// Safe because both start and end are on ASCII boundaries ('<' and '>')
html.replace_range(start..end, "");
} else {
// No close tag found; remove from open to end
html.truncate(s);
// No close tag found; drop from the opening tag to end
html.truncate(start);
break;
}
guard += 1;
@@ -2887,14 +2946,16 @@ async fn handle_web_fetch(
let sanitized = strip_noisy_tags(html);
let markdown = converter.convert(&sanitized)?;
let mut truncated = false;
let rendered = if markdown.len() > max_chars {
truncated = true;
let mut s = markdown;
s.truncate(max_chars);
s.push_str("\n\n… (truncated)\n");
s
} else {
markdown
let rendered = {
let char_count = markdown.chars().count();
if char_count > max_chars {
truncated = true;
let mut s: String = markdown.chars().take(max_chars).collect();
s.push_str("\n\n… (truncated)\n");
s
} else {
markdown
}
};
Ok((rendered, truncated))
}
@@ -1483,11 +1483,11 @@ impl WidgetRef for ChatComposer {
Span::raw(" "), // Space before spinner
Span::styled(
spinner.to_string(),
Style::default().fg(crate::colors::primary()),
Style::default().fg(crate::colors::info()),
),
Span::styled(
format!(" {}... ", self.status_message),
Style::default().fg(crate::colors::primary()),
Style::default().fg(crate::colors::info()),
), // Space after spinner and after text
])
.centered();
+15 -143
View File
@@ -966,7 +966,14 @@ impl ChatWidget<'_> {
.get(&ev.call_id)
.map(|rc| rc.command.join(" "))
.unwrap_or_else(|| "command".to_string());
let preview_short = if preview.len() > 40 { format!("{}…", &preview[..40]) } else { preview };
// Truncate preview safely at char boundaries (avoid slicing in the middle of UTF-8 like '❯')
let preview_short = if preview.chars().count() > 40 {
let mut truncated: String = preview.chars().take(40).collect();
truncated.push('…');
truncated
} else {
preview
};
self.bottom_pane
.update_status_text(format!("running command: {}", preview_short));
}
@@ -2123,35 +2130,10 @@ impl ChatWidget<'_> {
let new_label = exec_label(new_exec);
let is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "Listed" | "Ran" | "Reading..." | "Searching..." | "Listing..." | "Running...");
if !last_label.is_empty() && last_label == new_label && is_joinable_label(last_label) {
// Merge by rendered lines to preserve formatting
let last_lines = last_box.display_lines();
let new_lines = new_cell.display_lines();
let mut combined = last_lines.clone();
while combined
.last()
.map(|l| crate::render::line_utils::is_blank_line_trim(l))
.unwrap_or(false)
{
combined.pop();
}
let mut body: Vec<ratatui::text::Line<'static>> = new_lines.into_iter().skip(1).collect();
while body.first().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.remove(0);
}
while body.last().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.pop();
}
if let Some(first_line) = body.first_mut() {
if let Some(first_span) = first_line.spans.get_mut(0) {
if first_span.content == " └ " || first_span.content == "└ " {
first_span.content = " ".into();
}
}
}
combined.extend(body);
// Coalesce adjacent Read entries of the same file with contiguous ranges
coalesce_read_ranges_in_lines(&mut combined);
*last_box = Box::new(history_cell::PlainHistoryCell { lines: combined, kind: history_cell::HistoryCellType::Plain });
// Merge as a styled MergedExecCell to preserve per-exec formatting
let mut merged = history_cell::MergedExecCell::from_exec(last_exec);
merged.push_exec(new_exec);
*last_box = Box::new(merged);
self.autoscroll_if_near_bottom();
self.bottom_pane.set_has_chat_history(true);
self.process_animation_cleanup();
@@ -2208,120 +2190,9 @@ impl ChatWidget<'_> {
return;
}
} else {
// Also allow merging into an already-merged PlainHistoryCell produced above
if let Some(new_exec) = (&*new_cell).as_any().downcast_ref::<history_cell::ExecCell>() {
// Only merge completed exec cells into a prior merged block
if new_exec.output.is_none() { /* do not merge running exec */ }
else {
// Compute the label for the incoming exec
let exec_label = |e: &history_cell::ExecCell| -> &'static str {
let action = history_cell::action_from_parsed(&e.parsed);
match (&e.output, action) {
(None, "read") => "Reading...",
(None, "search") => "Searching...",
(None, "list") => "Listing...",
(None, _) => "Running...",
(Some(o), "read") if o.exit_code == 0 => "Read",
(Some(o), "search") if o.exit_code == 0 => "Searched",
(Some(o), "list") if o.exit_code == 0 => "Listed",
(Some(o), _) if o.exit_code == 0 => "Ran",
_ => "",
}
};
let new_label = exec_label(new_exec);
let is_joinable_label = |s: &str| matches!(s, "Searched" | "Read" | "Listed" | "Ran" | "Reading..." | "Searching..." | "Listing..." | "Running...");
if let Some(last_plain) = last_box.as_any_mut().downcast_mut::<history_cell::PlainHistoryCell>() {
// Extract the header label from the first line (best-effort)
let last_lines_snapshot = last_plain.lines.clone();
let last_header = last_lines_snapshot.first().and_then(|l| l.spans.get(0)).map(|s| s.content.clone().to_string()).unwrap_or_default();
if !new_label.is_empty() && is_joinable_label(new_label) && last_header == new_label {
// Merge by appending the new body's content lines
let new_lines = new_cell.display_lines();
let mut combined = last_plain.lines.clone();
while combined
.last()
.map(|l| crate::render::line_utils::is_blank_line_trim(l))
.unwrap_or(false)
{
combined.pop();
}
let mut body: Vec<ratatui::text::Line<'static>> = new_lines.into_iter().skip(1).collect();
while body.first().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.remove(0);
}
while body.last().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.pop();
}
if let Some(first_line) = body.first_mut() {
if let Some(first_span) = first_line.spans.get_mut(0) {
if first_span.content == " └ " || first_span.content == "└ " {
first_span.content = " ".into();
}
}
}
combined.extend(body);
// Coalesce adjacent Read entries of the same file with contiguous ranges
coalesce_read_ranges_in_lines(&mut combined);
last_plain.lines = combined;
self.autoscroll_if_near_bottom();
self.bottom_pane.set_has_chat_history(true);
self.process_animation_cleanup();
self.app_event_tx.send(AppEvent::RequestRedraw);
return;
}
}
}
}
// Also allow merging Tool cells into a prior merged PlainHistoryCell with same header
if let Some(new_tool) = (&*new_cell).as_any().downcast_ref::<history_cell::ToolCallCell>() {
if let Some(last_plain) = last_box.as_any_mut().downcast_mut::<history_cell::PlainHistoryCell>() {
let last_lines_snapshot = last_plain.lines.clone();
let last_header = last_lines_snapshot
.first()
.and_then(|l| l.spans.get(0))
.map(|s| s.content.clone().to_string())
.unwrap_or_default();
let new_lines = new_tool.display_lines();
let new_header = new_lines
.first()
.and_then(|l| l.spans.get(0))
.map(|s| s.content.clone().to_string())
.unwrap_or_default();
if !new_header.is_empty() && new_header == last_header {
let mut combined = last_plain.lines.clone();
while combined
.last()
.map(|l| crate::render::line_utils::is_blank_line_trim(l))
.unwrap_or(false)
{
combined.pop();
}
let mut body: Vec<ratatui::text::Line<'static>> = new_lines.into_iter().skip(1).collect();
while body.first().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.remove(0);
}
while body.last().map(|l| crate::render::line_utils::is_blank_line_trim(l)).unwrap_or(false) {
body.pop();
}
if let Some(first_line) = body.first_mut() {
if let Some(first_span) = first_line.spans.get_mut(0) {
if first_span.content == " └ " || first_span.content == "└ " {
first_span.content = " ".into();
}
}
}
combined.extend(body);
last_plain.lines = combined;
self.autoscroll_if_near_bottom();
self.bottom_pane.set_has_chat_history(true);
self.process_animation_cleanup();
self.app_event_tx.send(AppEvent::RequestRedraw);
return;
}
}
}
// Skip merging Exec into PlainHistoryCell; prefer typed MergedExecCell merges only.
}
// (disabled) merging Tool cells into prior PlainHistoryCell to keep logic simpler here
}
// Store in memory for local rendering
@@ -7259,6 +7130,7 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
// Coalesce adjacent Read entries of the same file with contiguous ranges in a rendered lines vector.
// Expects the vector to contain a header line at index 0 (e.g., "Read"). Modifies in place.
#[allow(dead_code)]
fn coalesce_read_ranges_in_lines(lines: &mut Vec<ratatui::text::Line<'static>>) {
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
+17 -14
View File
@@ -132,19 +132,22 @@ pub(super) fn create_diff_summary_with_width(
.add_modifier(Modifier::BOLD),
};
header_spans.push(RtSpan::styled(title.to_owned(), title_style));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(crate::colors::success()),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(crate::colors::error()),
));
header_spans.push(RtSpan::raw(")"));
// Only include aggregate counts in header for approval requests; omit for apply/updated.
if matches!(event_type, PatchEventType::ApprovalRequest) {
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(crate::colors::success()),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(crate::colors::error()),
));
header_spans.push(RtSpan::raw(")"));
}
out.push(RtLine::from(header_spans));
// Per-file lines with prefix
@@ -161,7 +164,7 @@ pub(super) fn create_diff_summary_with_width(
f.display_path.clone(),
Style::default().fg(crate::colors::text_dim()),
));
// Counts
// Per-file counts shown inline in chat summary
spans.push(RtSpan::styled(" (".to_string(), Style::default().fg(crate::colors::text_dim())));
spans.push(RtSpan::styled(
format!("+{}", f.added),
+318 -21
View File
@@ -1159,6 +1159,76 @@ impl MergedExecCell {
let (pre, out) = exec.exec_render_parts();
self.segments.push((pre, out));
}
// Build an aggregated preamble for Read segments by collapsing multiple
// ranges of the same file into a single combined range. Returns None for
// non-Read kinds or when parsing fails (fallback to per-segment rendering).
fn aggregated_read_preamble_lines(&self) -> Option<Vec<Line<'static>>> {
if self.kind != ExecKind::Read {
return None;
}
use std::collections::BTreeMap;
use ratatui::text::Span;
// filename -> (min_start, max_end)
let mut ranges: BTreeMap<String, (u32, u32)> = BTreeMap::new();
// Helper: parse a logical display line like
// "└ history_cell.rs (lines 1 to 240)"
// into (filename, start, end). Returns None on unknown formats.
let parse_line = |line: &Line<'_>| -> Option<(String, u32, u32)> {
let flat: String = line
.spans
.iter()
.map(|s| s.content.as_ref())
.collect();
let s = flat.trim_start();
let s = s.strip_prefix("└ ").unwrap_or(s);
let s = s.strip_prefix(" ").unwrap_or(s);
let (left, right) = s.rsplit_once(" (")?; // split before annotation
let ann = right.trim_end_matches(')');
// Only handle the common "(lines A to B)" pattern here
if let Some(rest) = ann.strip_prefix("lines ") {
if let Some((a, b)) = rest.split_once(" to ") {
let start: u32 = a.trim().parse().ok()?;
let end: u32 = b.trim().parse().ok()?;
return Some((left.trim().to_string(), start, end));
}
}
None
};
for (pre_raw, _out_raw) in &self.segments {
let mut pre = trim_empty_lines(pre_raw.clone());
if !pre.is_empty() { pre.remove(0); } // drop per-segment header
for line in pre.iter() {
if let Some((fname, a, b)) = parse_line(line) {
let entry = ranges.entry(fname).or_insert((a, b));
entry.0 = entry.0.min(a);
entry.1 = entry.1.max(b);
} else {
// Unknown/unsupported format; abort aggregation
return None;
}
}
}
if ranges.is_empty() { return None; }
// Build display lines with a single leading "└ " for the first file
let mut out: Vec<Line<'static>> = Vec::new();
for (i, (fname, (start, end))) in ranges.into_iter().enumerate() {
let mut spans: Vec<Span<'static>> = Vec::new();
let prefix = if i == 0 { "└ " } else { " " };
spans.push(Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)));
spans.push(Span::styled(fname, Style::default().fg(crate::colors::text())));
let rest = format!(" (lines {} to {})", start, end);
spans.push(Span::styled(rest, Style::default().fg(crate::colors::text_dim())));
out.push(Line::from(spans));
}
Some(out)
}
}
impl HistoryCell for MergedExecCell {
@@ -1167,6 +1237,64 @@ impl HistoryCell for MergedExecCell {
fn kind(&self) -> HistoryCellType {
HistoryCellType::Exec { kind: self.kind, status: ExecStatus::Success }
}
fn desired_height(&self, width: u16) -> u16 {
// Match custom_render_with_skip exactly:
// - Single shared header row (1)
// - For each segment:
// - Measure preamble after dropping the per-segment header
// and normalizing the leading "└ " prefix at full `width`.
// - Measure output inside a left-bordered block with left padding,
// which reduces the effective wrapping width by 2 columns.
let mut total: u16 = 1; // shared header (e.g., "Ran", "Read")
let pre_wrap_width = width;
let out_wrap_width = width.saturating_sub(2);
if let Some(agg_pre) = self.aggregated_read_preamble_lines() {
let pre_rows: u16 = Paragraph::new(Text::from(agg_pre))
.wrap(Wrap { trim: false })
.line_count(pre_wrap_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(pre_rows);
for (_pre_raw, out_raw) in &self.segments {
let out = trim_empty_lines(out_raw.clone());
let out_rows: u16 = Paragraph::new(Text::from(out))
.wrap(Wrap { trim: false })
.line_count(out_wrap_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(out_rows);
}
return total;
}
for (pre_raw, out_raw) in &self.segments {
// Build preamble like the renderer: trim, drop first header line, ensure prefix
let mut pre = trim_empty_lines(pre_raw.clone());
if !pre.is_empty() { pre.remove(0); }
if let Some(first) = pre.first_mut() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
let already = flat.trim_start().starts_with("└ ") || flat.trim_start().starts_with(" └ ");
if !already {
first.spans.insert(0, Span::styled("└ ", Style::default().fg(crate::colors::text_dim())));
}
}
let out = trim_empty_lines(out_raw.clone());
let pre_rows: u16 = Paragraph::new(Text::from(pre))
.wrap(Wrap { trim: false })
.line_count(pre_wrap_width)
.try_into()
.unwrap_or(0);
let out_rows: u16 = Paragraph::new(Text::from(out))
.wrap(Wrap { trim: false })
.line_count(out_wrap_width)
.try_into()
.unwrap_or(0);
total = total.saturating_add(pre_rows).saturating_add(out_rows);
}
total
}
fn display_lines(&self) -> Vec<Line<'static>> {
// Fallback textual form: concatenate all preambles + outputs with blank separators.
let mut out: Vec<Line<'static>> = Vec::new();
@@ -1179,8 +1307,7 @@ impl HistoryCell for MergedExecCell {
}
fn has_custom_render(&self) -> bool { true }
fn custom_render_with_skip(&self, area: Rect, buf: &mut Buffer, mut skip_rows: u16) {
// Render each segment exactly like ExecCell.custom_render_with_skip, one after another,
// inserting a blank separator row between segments.
// Single shared header (e.g., "Ran") then each segment's command + output.
let bg = Style::default().bg(crate::colors::background()).fg(crate::colors::text());
// Hard clear area first
for y in area.y..area.y.saturating_add(area.height) {
@@ -1189,13 +1316,145 @@ impl HistoryCell for MergedExecCell {
}
}
// Build one header line based on exec kind
let header_line = match self.kind {
ExecKind::Read => Line::styled("Read", Style::default().fg(crate::colors::text())),
ExecKind::Search => Line::styled("Searched", Style::default().fg(crate::colors::text())),
ExecKind::List => Line::styled("List Files", Style::default().fg(crate::colors::text())),
ExecKind::Run => Line::styled(
"Ran",
Style::default()
.fg(crate::colors::text_bright())
.add_modifier(Modifier::BOLD),
),
};
let mut cur_y = area.y;
let end_y = area.y.saturating_add(area.height);
for (seg_idx, (pre, out)) in self.segments.iter().enumerate() {
// Render or skip header line
if skip_rows == 0 {
if cur_y < end_y {
let txt = Text::from(vec![header_line.clone()]);
Paragraph::new(txt)
.block(Block::default().style(bg))
.wrap(Wrap { trim: false })
.render(Rect { x: area.x, y: cur_y, width: area.width, height: 1 }, buf);
cur_y = cur_y.saturating_add(1);
}
} else {
skip_rows = skip_rows.saturating_sub(1);
}
// Helper: ensure the first visible line of `lines` begins with a dim "└ "
let ensure_prefix = |lines: &mut Vec<Line<'static>>| {
if let Some(first) = lines.first_mut() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
let already = flat.trim_start().starts_with("└ ") || flat.trim_start().starts_with(" └ ");
if !already {
first.spans.insert(0, Span::styled("└ ", Style::default().fg(crate::colors::text_dim())));
}
}
};
// Special aggregated rendering for Read: collapse file ranges
if self.kind == ExecKind::Read {
// Build aggregated preamble once
let agg_pre = self
.aggregated_read_preamble_lines()
.unwrap_or_else(|| {
// Fallback: concatenate per-segment preambles
let mut all: Vec<Line<'static>> = Vec::new();
for (i, (pre_raw, _)) in self.segments.iter().enumerate() {
let mut pre = trim_empty_lines(pre_raw.clone());
if !pre.is_empty() { pre.remove(0); }
if i == 0 {
// ensure leading prefix
if let Some(first) = pre.first_mut() {
let flat: String = first.spans.iter().map(|s| s.content.as_ref()).collect();
let already = flat.trim_start().starts_with("└ ") || flat.trim_start().starts_with(" └ ");
if !already {
first.spans.insert(0, Span::styled("└ ", Style::default().fg(crate::colors::text_dim())));
}
}
}
all.extend(pre);
}
all
});
// Header was already handled above (including skip accounting).
// Render aggregated preamble next using the current skip_rows.
let pre_text = Text::from(agg_pre);
let pre_wrap_width = area.width;
let pre_total: u16 = Paragraph::new(pre_text.clone())
.wrap(Wrap { trim: false })
.line_count(pre_wrap_width)
.try_into()
.unwrap_or(0);
if cur_y < end_y {
let pre_skip = skip_rows.min(pre_total);
let pre_remaining = pre_total.saturating_sub(pre_skip);
let pre_height = pre_remaining.min(end_y.saturating_sub(cur_y));
if pre_height > 0 {
Paragraph::new(pre_text)
.block(Block::default().style(bg))
.wrap(Wrap { trim: false })
.scroll((pre_skip, 0))
.style(bg)
.render(Rect { x: area.x, y: cur_y, width: area.width, height: pre_height }, buf);
cur_y = cur_y.saturating_add(pre_height);
}
skip_rows = skip_rows.saturating_sub(pre_skip);
}
// Render each segment's output only
let out_wrap_width = area.width.saturating_sub(2);
for (_pre_raw, out_raw) in self.segments.iter() {
if cur_y >= end_y { break; }
let out = trim_empty_lines(out_raw.clone());
let out_text = Text::from(out.clone());
let out_total: u16 = Paragraph::new(out_text.clone())
.wrap(Wrap { trim: false })
.line_count(out_wrap_width)
.try_into()
.unwrap_or(0);
let out_skip = skip_rows.min(out_total);
let out_remaining = out_total.saturating_sub(out_skip);
let out_height = out_remaining.min(end_y.saturating_sub(cur_y));
if out_height > 0 {
let out_area = Rect { x: area.x, y: cur_y, width: area.width, height: out_height };
let block = Block::default()
.borders(Borders::LEFT)
.border_style(Style::default().fg(crate::colors::border_dim()).bg(crate::colors::background()))
.style(Style::default().bg(crate::colors::background()))
.padding(Padding { left: 1, right: 0, top: 0, bottom: 0 });
Paragraph::new(out_text)
.block(block)
.wrap(Wrap { trim: false })
.scroll((out_skip, 0))
.style(Style::default().bg(crate::colors::background()).fg(crate::colors::text_dim()))
.render(out_area, buf);
cur_y = cur_y.saturating_add(out_height);
}
skip_rows = skip_rows.saturating_sub(out_skip);
}
return;
}
for (pre_raw, out_raw) in self.segments.iter() {
if cur_y >= end_y { break; }
// Drop the per-segment header line (first element)
let mut pre = trim_empty_lines(pre_raw.clone());
if !pre.is_empty() { pre.remove(0); }
// Normalize command prefix for generic execs
ensure_prefix(&mut pre);
let out = trim_empty_lines(out_raw.clone());
// Measure with same widths as ExecCell
let pre_text = Text::from(trim_empty_lines(pre.clone()));
let out_text = Text::from(trim_empty_lines(out.clone()));
let pre_text = Text::from(pre.clone());
let out_text = Text::from(out.clone());
let pre_wrap_width = area.width;
let out_wrap_width = area.width.saturating_sub(2);
let pre_total: u16 = Paragraph::new(pre_text.clone())
@@ -1209,25 +1468,25 @@ impl HistoryCell for MergedExecCell {
.try_into()
.unwrap_or(0);
// Skip rows across pre + out
// Apply skip to pre, then out
let pre_skip = skip_rows.min(pre_total);
let out_skip = skip_rows.saturating_sub(pre_total).min(out_total);
// Remaining space for this segment
// Render pre
let pre_remaining = pre_total.saturating_sub(pre_skip);
let pre_height = pre_remaining.min(end_y.saturating_sub(cur_y));
if pre_height > 0 {
let pre_area = Rect { x: area.x, y: cur_y, width: area.width, height: pre_height };
Paragraph::new(pre_text)
.block(Block::default().style(bg))
.wrap(Wrap { trim: false })
.scroll((pre_skip, 0))
.style(bg)
.render(pre_area, buf);
.render(Rect { x: area.x, y: cur_y, width: area.width, height: pre_height }, buf);
cur_y = cur_y.saturating_add(pre_height);
}
if cur_y >= end_y { break; }
// Render out as bordered, dim block
let out_remaining = out_total.saturating_sub(out_skip);
let out_height = out_remaining.min(end_y.saturating_sub(cur_y));
if out_height > 0 {
@@ -1246,18 +1505,8 @@ impl HistoryCell for MergedExecCell {
cur_y = cur_y.saturating_add(out_height);
}
// Separator row between segments (blank). Count toward skipping/rendering.
if seg_idx + 1 < self.segments.len() && cur_y < end_y {
if skip_rows == 0 {
// already cleared, just move the cursor down one
cur_y = cur_y.saturating_add(1);
} else {
skip_rows = skip_rows.saturating_sub(1);
}
}
// Consume skip_rows used by this segment
let consumed = pre_total + out_total + if seg_idx + 1 < self.segments.len() { 1 } else { 0 };
// Consume skip rows used in this segment
let consumed = pre_total + out_total;
skip_rows = skip_rows.saturating_sub(consumed);
}
}
@@ -1646,12 +1895,60 @@ fn exec_render_parts_parsed(
}
}
// Collapse adjacent Read ranges for the same file inside a single exec's preamble
coalesce_read_ranges_in_lines_local(&mut pre);
// Output: show stdout only for real run commands; errors always included
let show_stdout = action == "run";
let out = output_lines(output, !show_stdout, false);
(pre, out)
}
// Local helper: coalesce "<file> (lines A to B)" entries when contiguous.
fn coalesce_read_ranges_in_lines_local(lines: &mut Vec<Line<'static>>) {
use ratatui::text::Span;
if lines.len() <= 2 { return; }
fn parse_read_line(line: &Line<'_>) -> Option<(String, u32, u32, String)> {
if line.spans.is_empty() { return None; }
let prefix = line.spans[0].content.to_string();
if !(prefix == "└ " || prefix == " ") { return None; }
let rest: String = line.spans.iter().skip(1).map(|s| s.content.as_ref()).collect();
if let Some(idx) = rest.rfind(" (lines ") {
let fname = rest[..idx].to_string();
let tail = &rest[idx + 1..];
if tail.starts_with("(lines ") && tail.ends_with(")") {
let inner = &tail[7..tail.len() - 1];
if let Some((s1, s2)) = inner.split_once(" to ") {
if let (Ok(a), Ok(b)) = (s1.trim().parse::<u32>(), s2.trim().parse::<u32>()) {
return Some((fname, a, b, prefix));
}
}
}
}
None
}
let mut i: usize = 1; // skip header
while i + 1 < lines.len() {
let left = parse_read_line(&lines[i]);
let right = parse_read_line(&lines[i + 1]);
if let (Some((fname_a, mut a1, mut a2, prefix_a)), Some((fname_b, b1, b2, _))) = (left, right) {
if fname_a == fname_b && (b1 == a2 || b1 == a2 + 1) {
a1 = a1.min(b1);
a2 = a2.max(b2);
let new_spans: Vec<Span<'static>> = vec![
Span::styled(prefix_a, Style::default().add_modifier(Modifier::DIM)),
Span::styled(fname_a, Style::default().fg(crate::colors::text())),
Span::styled(format!(" (lines {} to {})", a1, a2), Style::default().fg(crate::colors::text_dim())),
];
lines[i] = Line::from(new_spans);
lines.remove(i + 1);
continue;
}
}
i += 1;
}
}
impl WidgetRef for &ExecCell {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
Paragraph::new(Text::from(self.display_lines_trimmed()))