From 270d7923abd13d9f6b211c223c705e4c93a4c7d3 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 22 Apr 2026 01:17:58 +0900 Subject: [PATCH] =?UTF-8?q?TUI=E3=81=AEEdit=E3=83=84=E3=83=BC=E3=83=AB?= =?UTF-8?q?=E5=91=A8=E3=82=8A=E3=81=AE=E8=A1=A8=E7=A4=BA=E3=81=A8=E3=82=AB?= =?UTF-8?q?=E3=83=A9=E3=83=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/app.rs | 70 +++++++++- crates/tui/src/block.rs | 5 + crates/tui/src/tool.rs | 294 +++++++++++++++++++++++++++++++--------- crates/tui/src/ui.rs | 178 ++++++++++++++++-------- 4 files changed, 420 insertions(+), 127 deletions(-) diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 7e5a075d..533ba792 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -125,6 +125,7 @@ impl App { args_stream: String::new(), arguments: None, state: ToolCallState::Pending, + edit_snapshot: None, })); } Event::ToolCallArgsDelta { id, json } => { @@ -158,11 +159,26 @@ impl App { output, is_error, } => { + // Pull the name / args out first so we can look at the + // (immutable) cache before taking the mutable block + // borrow below. + let (name, args) = self + .find_tool_call_mut(&id) + .map(|b| (b.name.clone(), b.arguments.clone())) + .unwrap_or_default(); + let edit_snapshot = if !is_error && name == "Edit" { + args.as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|v| v["file_path"].as_str().map(|s| s.to_owned())) + .and_then(|path| self.cache.get(&path).map(|s| s.to_owned())) + } else { + None + }; + if let Some(b) = self.find_tool_call_mut(&id) { - // Capture data we need for cache updates before we - // move `output` into the new state. - let name = b.name.clone(); - let args = b.arguments.clone(); + if edit_snapshot.is_some() { + b.edit_snapshot = edit_snapshot; + } b.state = if is_error { ToolCallState::Error { summary, @@ -377,6 +393,7 @@ impl App { args_stream: arguments.clone().unwrap_or_default(), arguments, state: ToolCallState::Executing, + edit_snapshot: None, })); } "tool_result" => { @@ -384,9 +401,22 @@ impl App { let summary = item["summary"].as_str().unwrap_or("").to_owned(); let output = item["content"].as_str().map(|s| s.to_owned()); let is_error = item["is_error"].as_bool().unwrap_or(false); + let (name, args) = self + .find_tool_call_mut(&id) + .map(|b| (b.name.clone(), b.arguments.clone())) + .unwrap_or_default(); + let edit_snapshot = if !is_error && name == "Edit" { + args.as_deref() + .and_then(|s| serde_json::from_str::(s).ok()) + .and_then(|v| v["file_path"].as_str().map(|s| s.to_owned())) + .and_then(|path| self.cache.get(&path).map(|s| s.to_owned())) + } else { + None + }; if let Some(tc) = self.find_tool_call_mut(&id) { - let name = tc.name.clone(); - let args = tc.arguments.clone(); + if edit_snapshot.is_some() { + tc.edit_snapshot = edit_snapshot; + } tc.state = if is_error { ToolCallState::Error { summary, @@ -436,6 +466,28 @@ pub fn fmt_tokens(n: u64) -> String { } } +/// Strip the `cat -n` line-number gutter that the Read tool prepends to +/// its output (one `"{n:>6}\t{content}"` per line) and return the raw +/// file body. Lines that don't match the pattern are kept verbatim, so +/// unrelated payloads pass through unharmed. +fn strip_cat_n_prefix(formatted: &str) -> String { + let mut out = String::with_capacity(formatted.len()); + let mut first = true; + for line in formatted.split('\n') { + if !first { + out.push('\n'); + } + first = false; + match line.split_once('\t') { + Some((prefix, rest)) if prefix.trim().chars().all(|c| c.is_ascii_digit()) => { + out.push_str(rest); + } + _ => out.push_str(line), + } + } + out +} + pub fn notification_source_label(source: NotificationSource) -> &'static str { match source { NotificationSource::Pod => "pod", @@ -464,7 +516,11 @@ fn apply_cache_update( return; }; if let Some(content) = output { - cache.put(path, content.to_owned()); + // The Read tool emits a `cat -n` style display: each + // line is "{lineno:>6}\tcontent". Strip that framing + // so the cache mirrors the real file body and the + // Edit diff renderer has a faithful "before" view. + cache.put(path, strip_cat_n_prefix(content)); } } "Write" => { diff --git a/crates/tui/src/block.rs b/crates/tui/src/block.rs index d4477aef..f825b45b 100644 --- a/crates/tui/src/block.rs +++ b/crates/tui/src/block.rs @@ -48,6 +48,11 @@ pub struct ToolCallBlock { /// Final arguments text once `ToolCallDone` lands. pub arguments: Option, pub state: ToolCallState, + /// For Edit tool calls: snapshot of the file content *before* the + /// edit was applied to the cache. Captured at result time so the + /// diff renderer can reproduce the old-content context even after + /// subsequent mutations have rolled the cache forward. + pub edit_snapshot: Option, } pub enum ToolCallState { diff --git a/crates/tui/src/tool.rs b/crates/tui/src/tool.rs index 5d81d465..ae2cc957 100644 --- a/crates/tui/src/tool.rs +++ b/crates/tui/src/tool.rs @@ -8,6 +8,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::block::{Block, ToolCallBlock, ToolCallState}; use crate::cache::FileCache; @@ -28,6 +29,7 @@ pub fn render_tool( cache: &FileCache, blocks: &[Block], start: usize, + width: u16, mode: Mode, ) -> ToolRenderOutput { let Some(Block::ToolCall(tc)) = blocks.get(start) else { @@ -40,7 +42,7 @@ pub fn render_tool( match tc.name.as_str() { "Read" => render_read_aggregate(blocks, start, mode), "Write" => single(render_write(cache, tc, mode)), - "Edit" => single(render_edit(cache, tc, mode)), + "Edit" => single(render_edit(cache, tc, width, mode)), "Glob" => single(render_search(tc, mode, "Glob")), "Grep" => single(render_search(tc, mode, "Grep")), _ => single(render_default(tc, mode)), @@ -83,9 +85,9 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend let mut lines: Vec> = Vec::new(); let header = if in_progress { - format!("[tool] Read — reading ({count} file{}…)", plural(count)) + format!("Read — reading ({count} file{}…)", plural(count)) } else { - format!("[tool] Read — {count} file{} read", plural(count)) + format!("Read — {count} file{} read", plural(count)) }; lines.push(Line::from(vec![ Span::styled(header, tool_style), @@ -161,7 +163,7 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec Vec Vec Vec> { +fn render_edit(cache: &FileCache, tc: &ToolCallBlock, width: u16, mode: Mode) -> Vec> { let args = parsed_args(tc); let path = args .as_ref() @@ -229,7 +231,7 @@ fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec Vec Vec> { +fn build_edit_diff(content: &str, old: &str, new: &str, width: u16) -> Vec> { // Locate the first (and typically only) match. let Some(idx) = content.find(old) else { - return vec![Line::from(vec![ - Span::raw(" "), - Span::styled( - "(old_string not found in cached content)".to_owned(), - Style::default().fg(Color::DarkGray), - ), - ])]; + return vec![Line::from(Span::styled( + " (old_string not found in cached content)".to_owned(), + Style::default().fg(Color::DarkGray), + ))]; }; let end = idx + old.len(); // Convert byte ranges to line ranges. - let before = &content[..idx]; - let line_of_idx = before.lines().count(); - // `lines()` omits a trailing empty line when the text ends in \n — - // fine for our purposes (we only need approximate line ranges). - let replaced_line_count = content[idx..end].lines().count().max(1); - + // + // Zero-based line index of `idx` = number of newlines preceding + // it. `before.lines().count()` does NOT give this: for content + // without a trailing newline it over-counts on the last line + // (e.g. "h".lines() == ["h"].count() == 1 but "h" is still on + // line 0). let all_lines: Vec<&str> = content.lines().collect(); + let line_of_idx = content[..idx].matches('\n').count().min(all_lines.len()); + let replaced_line_count = content[idx..end].lines().count().max(1); + // Keep subsequent slice math in-bounds no matter what the + // positions say — Edit never wants to panic the whole TUI just + // because cached content and live args disagree. + let replaced_end = (line_of_idx + replaced_line_count).min(all_lines.len()); let ctx_start = line_of_idx.saturating_sub(EDIT_DIFF_CONTEXT); - let ctx_end = (line_of_idx + replaced_line_count + EDIT_DIFF_CONTEXT).min(all_lines.len()); + let ctx_end = (replaced_end + EDIT_DIFF_CONTEXT).min(all_lines.len()); - let ctx_style = Style::default().fg(Color::Gray); - let minus_style = Style::default().fg(Color::Red); - let plus_style = Style::default().fg(Color::Green); + let old_line_count = old.lines().count().max(1); + let new_line_count = new.lines().count().max(1); + + // Width for the line-number gutter: fit the largest number we'll + // print across either file's version of this hunk. + let max_line = ctx_end + .max(line_of_idx + new_line_count) + .max(1); + let num_w = max_line.to_string().len(); + + // BG-highlighted rows for -/+ so the change stripe extends full + // width; context lines stay plain. FG is left at terminal default + // on colored rows so contrast survives user terminal themes. + let ctx_text = Style::default().fg(Color::Gray); + let num_style_ctx = Style::default().fg(Color::DarkGray); + let num_style_dim = Style::default().fg(Color::White); + // kanagawa-nvim palette: + // winterRed #43242B / winterGreen #2B3328 — diff row backgrounds + // autumnRed #C34043 / autumnGreen #76946A — git add/delete markers + let minus_line_style = Style::default().bg(Color::Rgb(0x43, 0x24, 0x2B)); + let plus_line_style = Style::default().bg(Color::Rgb(0x2B, 0x33, 0x28)); + let minus_marker_style = Style::default().fg(Color::Rgb(0xC3, 0x40, 0x43)); + let plus_marker_style = Style::default().fg(Color::Rgb(0x76, 0x94, 0x6A)); let mut lines: Vec> = Vec::new(); - for l in &all_lines[ctx_start..line_of_idx] { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!(" {l}"), ctx_style), - ])); + // Layout per row (independent of outer history padding): + // 1-space gutter + line number (right-aligned to num_w) + 1 space + // + 1-char marker (" " / "-" / "+") + content + let num_gutter_w = num_w + 2; // leading sp + num + trailing sp + let fmt_num = |n: usize| format!(" {n:>num_w$} ", n = n, num_w = num_w); + let blank_num = " ".repeat(num_gutter_w); + + // Pre-context: 1-based line numbers in the original file. + for (offset, l) in all_lines[ctx_start..line_of_idx].iter().enumerate() { + let n = ctx_start + offset + 1; + emit_diff_row( + &mut lines, + fmt_num(n), + &blank_num, + num_style_ctx, + ' ', + Style::default(), + l, + Style::default(), // no BG + ctx_text, + width, + ); } - for l in old.lines() { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("-{l}"), minus_style), - ])); + // Removed: old line numbers (line_of_idx + offset + 1). Row BG is + // red; continuation rows keep the same BG via the wrap helper. + for (offset, l) in old.lines().enumerate() { + let n = line_of_idx + offset + 1; + emit_diff_row( + &mut lines, + fmt_num(n), + &blank_num, + num_style_dim, + '-', + minus_marker_style, + l, + minus_line_style, + Style::default(), + width, + ); } - for l in new.lines() { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!("+{l}"), plus_style), - ])); + // Added: new line numbers — same base as removed since everything + // above the hunk is unchanged. + for (offset, l) in new.lines().enumerate() { + let n = line_of_idx + offset + 1; + emit_diff_row( + &mut lines, + fmt_num(n), + &blank_num, + num_style_dim, + '+', + plus_marker_style, + l, + plus_line_style, + Style::default(), + width, + ); } - for l in &all_lines[line_of_idx + replaced_line_count..ctx_end] { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(format!(" {l}"), ctx_style), - ])); + // Post-context: positions relative to the ORIGINAL file so numbers + // stay consistent with the snapshot we're diffing against. After a + // successful edit the real file's numbering will shift by + // `new_line_count - old_line_count`, but the purpose of this view + // is to read the change itself, not to navigate the updated file. + for (offset, l) in all_lines[replaced_end..ctx_end].iter().enumerate() { + let n = replaced_end + offset + 1; + emit_diff_row( + &mut lines, + fmt_num(n), + &blank_num, + num_style_ctx, + ' ', + Style::default(), + l, + Style::default(), + ctx_text, + width, + ); } + // Suppress unused-variable lint for the value used only in + // `max_line` computation (kept for readability there). + let _ = old_line_count; + lines } +/// Emit one diff row, wrapping long content into continuation rows that +/// keep the vertical diff structure intact: +/// +/// ```text +/// 3 +very long line that overflows into a second terminal row +/// +of the same logical diff line +/// ``` +/// +/// `num_text` is the gutter text for the first row (e.g. " 3 "), +/// `blank_num` is the same width but all spaces for continuation rows. +/// `line_style` applies to every emitted row so BG-highlighted diffs +/// retain their highlight across the wrap. +#[allow(clippy::too_many_arguments)] +fn emit_diff_row( + out: &mut Vec>, + num_text: String, + blank_num: &str, + num_style: Style, + marker: char, + marker_style: Style, + content: &str, + line_style: Style, + content_text_style: Style, + width: u16, +) { + let total = width as usize; + let gutter_w = UnicodeWidthStr::width(num_text.as_str()); + // Reserve one more col for the marker char; anything beyond goes + // into the content. + let content_budget = total.saturating_sub(gutter_w).saturating_sub(1); + + let mut is_first = true; + let mut remaining = content; + loop { + let (chunk, rest) = if content_budget == 0 { + (remaining, "") + } else { + split_at_width(remaining, content_budget) + }; + + let gutter = if is_first { + num_text.clone() + } else { + blank_num.to_owned() + }; + let content_span = if content_text_style == Style::default() { + Span::raw(chunk.to_owned()) + } else { + Span::styled(chunk.to_owned(), content_text_style) + }; + let line = Line::from(vec![ + Span::styled(gutter, num_style), + Span::styled(marker.to_string(), marker_style), + content_span, + ]) + .style(line_style); + out.push(line); + + is_first = false; + remaining = rest; + if remaining.is_empty() { + break; + } + } +} + +/// Split `s` at the first char boundary whose accumulated display width +/// exceeds `max_cols`. Returns `(head, tail)` — head's display width is +/// guaranteed ≤ `max_cols`. +fn split_at_width(s: &str, max_cols: usize) -> (&str, &str) { + if max_cols == 0 { + return (s, ""); + } + let mut col = 0usize; + let mut end_byte = 0usize; + for (i, c) in s.char_indices() { + let cw = UnicodeWidthChar::width(c).unwrap_or(0); + if col + cw > max_cols { + return s.split_at(i); + } + col += cw; + end_byte = i + c.len_utf8(); + } + s.split_at(end_byte) +} + // --------------------------------------------------------------------- // Glob / Grep // --------------------------------------------------------------------- @@ -343,14 +507,14 @@ fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec Vec> { _ => state_suffix(&tc.state).to_owned(), }; let label = if suffix.is_empty() { - format!("[tool] {} — {}", tc.name, state_suffix(&tc.state)) + format!("{} — {}", tc.name, state_suffix(&tc.state)) } else { - format!("[tool] {} — {suffix}", tc.name) + format!("{} — {suffix}", tc.name) }; return vec![Line::from(vec![ Span::styled(label, tool_style), @@ -408,7 +572,7 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec> { let mut lines = vec![Line::from(vec![ Span::styled( - format!("[tool] {} — {}", tc.name, state_suffix(&tc.state)), + format!("{} — {}", tc.name, state_suffix(&tc.state)), tool_style, ), ])]; diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index d60da2e3..2290bc9d 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -18,7 +18,7 @@ use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap}; -use unicode_width::UnicodeWidthChar; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use protocol::{Greeting, NotificationLevel}; @@ -108,7 +108,7 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout { logical_turn_starts.push(logical.len()); } if matches!(block, Block::ToolCall(_)) { - let out = crate::tool::render_tool(&app.cache, &app.blocks, i, app.mode); + let out = crate::tool::render_tool(&app.cache, &app.blocks, i, width, app.mode); logical.extend(out.lines); i += out.consumed.max(1); continue; @@ -136,9 +136,6 @@ pub fn compute_history(app: &App, width: u16) -> HistoryLayout { HistoryLayout { lines, turn_starts } } -/// Maximum body lines a normal-mode block may emit before truncation. -const NORMAL_MAX_LINES: usize = 6; - /// Horizontal gutter around the log area. Applied via a /// [`Block`](ratatui::widgets::Block)'s padding so *every* row — including /// continuation rows from wrapping — sits inside the same margin, no @@ -188,7 +185,11 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { } /// Split one logical line into one-terminal-row `Line`s via char-aware -/// wrapping. Preserves per-span styles and the source line's alignment. +/// wrapping. Preserves per-span styles, the source line's alignment, +/// and the source line's own style. If the source line carries a +/// background color, each emitted row is padded to `width` with spaces +/// styled by that line style — so diff-style row highlights (red/green +/// backgrounds) extend cleanly to the right edge of the terminal. fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec>) { if width == 0 { out.push(line); @@ -196,6 +197,8 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec>) } let w = width as usize; let alignment = line.alignment; + let line_style = line.style; + let fill_to_width = line_style.bg.is_some(); let mut current: Vec> = Vec::new(); let mut row_width: usize = 0; @@ -219,7 +222,12 @@ fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec>) let push_row = |current: &mut Vec>, row_width: &mut usize, out: &mut Vec>| { - let mut l = Line::from(std::mem::take(current)); + if fill_to_width && *row_width < w { + let pad = w - *row_width; + current.push(Span::styled(" ".repeat(pad), line_style)); + *row_width = w; + } + let mut l = Line::from(std::mem::take(current)).style(line_style); if let Some(a) = alignment { l = l.alignment(a); } @@ -292,12 +300,15 @@ fn render_block_into( ))); } Block::UserMessage { text } => match mode { - Mode::Overview => push_overview_line(lines, text, MessageKind::User, "> "), - _ => push_padded_truncated(lines, text, MessageKind::User, mode), + Mode::Overview => push_overview_line(lines, text, width, MessageKind::User, "> "), + // User input and assistant prose are the primary readable + // content of a turn — never compressed in detail / normal. + // Only `overview` folds them to a single line. + _ => push_padded_lines(lines, text, MessageKind::User), }, Block::AssistantText { text } => match mode { - Mode::Overview => push_overview_line(lines, text, MessageKind::Assistant, ""), - _ => push_padded_truncated(lines, text, MessageKind::Assistant, mode), + Mode::Overview => push_overview_line(lines, text, width, MessageKind::Assistant, ""), + _ => push_padded_lines(lines, text, MessageKind::Assistant), }, // ToolCall is dispatched in `compute_history` via `tool::render_tool` // so it can consume multiple adjacent blocks (Read aggregation). @@ -318,11 +329,11 @@ fn render_block_into( let label = notification_source_label(*source); let text = format!("{prefix} {label}: {message}"); match mode { - Mode::Overview => push_overview_line(lines, &text, kind, ""), - _ => push_padded_truncated(lines, &text, kind, mode), + Mode::Overview => push_overview_line(lines, &text, width, kind, ""), + _ => push_padded_lines(lines, &text, kind), } } - Block::Compact(evt) => render_compact(lines, evt, mode), + Block::Compact(evt) => render_compact(lines, evt, width, mode), Block::TurnStats { requests, input_tokens, @@ -352,62 +363,119 @@ fn push_padded_lines(lines: &mut Vec>, text: &str, kind: MessageKi } } -/// Normal / detail padded text: detail prints every line; normal caps at -/// `NORMAL_MAX_LINES` and appends a "+N more" footer. -fn push_padded_truncated( - lines: &mut Vec>, - text: &str, - kind: MessageKind, - mode: Mode, -) { - if matches!(mode, Mode::Detail) { - push_padded_lines(lines, text, kind); - return; - } - let style = kind_style(kind); - let all: Vec<&str> = text.lines().collect(); - let shown = all.len().min(NORMAL_MAX_LINES); - for raw in &all[..shown] { - lines.push(Line::from(Span::styled((*raw).to_owned(), style))); - } - if all.len() > shown { - let hidden = all.len() - shown; - lines.push(Line::from(Span::styled( - format!("… +{hidden} more lines"), - Style::default().fg(Color::DarkGray), - ))); - } - if text.is_empty() { - lines.push(Line::from("")); - } -} - -/// Single-line summary for overview mode. First non-empty line of the -/// source text, with an optional prefix (e.g. "> " for user messages). +/// Single-line summary for overview mode. The output is clipped to +/// exactly one rendered terminal row at `width` columns — the first +/// non-empty logical line is truncated (with `…`) to fit alongside an +/// optional prefix and a `(+N lines)` tail that counts visual rows +/// hidden by the fold (auto-wrap inclusive, so a single long paragraph +/// that wraps to many rows correctly reports the hidden rows). fn push_overview_line( lines: &mut Vec>, text: &str, + width: u16, kind: MessageKind, prefix: &str, ) { let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); - let more = text.lines().count().saturating_sub(1); + let total_visual = count_visual_rows(text, width); + let first_visual = count_visual_rows(first, width); + + // Budget for the first line's truncated content. Reserve columns + // for the prefix up front and for the "(+N lines)" tail if we'll + // emit one. `more` counts visual rows hidden by the fold; the one + // row we're about to render is subtracted. + let total_cols = width.max(1) as usize; + let prefix_width = UnicodeWidthStr::width(prefix); + let more = total_visual.saturating_sub(1); + let tentative_tail = if more > 0 { + format!(" (+{more} lines)") + } else { + String::new() + }; + let avail = total_cols.saturating_sub(prefix_width); + // On very narrow widths where the tail alone would eat the row, + // drop it — keeping at least some of the actual content visible + // matters more than the hidden-rows label. + let (tail, budget) = { + let tw = UnicodeWidthStr::width(tentative_tail.as_str()); + if !tentative_tail.is_empty() && tw >= avail { + (String::new(), avail) + } else { + (tentative_tail, avail.saturating_sub(tw)) + } + }; + let first_width = UnicodeWidthStr::width(first); + let needs_truncation = first_width > budget || first_visual > 1; + let shown = if needs_truncation { + truncate_with_ellipsis(first, budget) + } else { + first.to_owned() + }; + let style = kind_style(kind); let mut spans: Vec> = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled(prefix.to_owned(), style)); } - spans.push(Span::styled(first.to_owned(), style)); - if more > 0 { - spans.push(Span::styled( - format!(" (+{more} lines)"), - Style::default().fg(Color::DarkGray), - )); + spans.push(Span::styled(shown, style)); + if !tail.is_empty() { + spans.push(Span::styled(tail, Style::default().fg(Color::DarkGray))); } lines.push(Line::from(spans)); } -fn render_compact(lines: &mut Vec>, evt: &CompactEvent, mode: Mode) { +/// Truncate `s` so its display width fits within `max_width`, appending +/// `…` when a cut is actually applied. A budget of 0 yields an empty +/// string; a budget of 1 drops the ellipsis and returns at most one +/// column of content so we never blow past the cap. +fn truncate_with_ellipsis(s: &str, max_width: usize) -> String { + if max_width == 0 { + return String::new(); + } + let s_width = UnicodeWidthStr::width(s); + if s_width <= max_width { + return s.to_owned(); + } + // Reserve 1 col for the ellipsis when we have room; on a 1-col + // budget there's no room for both content and the marker, so just + // clip to whatever single column fits. + let (inner_budget, append_ellipsis) = if max_width >= 2 { + (max_width - 1, true) + } else { + (max_width, false) + }; + let mut out = String::new(); + let mut w = 0usize; + for c in s.chars() { + let cw = UnicodeWidthChar::width(c).unwrap_or(0); + if w + cw > inner_budget { + break; + } + out.push(c); + w += cw; + } + if append_ellipsis { + out.push('…'); + } + out +} + +/// Visual row count of `text` when rendered at `width` columns with +/// simple char-based wrap. Each empty logical line counts as 1 row. +fn count_visual_rows(text: &str, width: u16) -> usize { + if text.is_empty() { + return 0; + } + let w = width.max(1) as usize; + let mut total = 0usize; + for line in text.lines() { + let lw = UnicodeWidthStr::width(line); + total += if lw == 0 { 1 } else { lw.div_ceil(w) }; + } + total.max(1) +} + +fn render_compact(lines: &mut Vec>, evt: &CompactEvent, width: u16, mode: Mode) { let (text, kind) = match evt { CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn), CompactEvent::Done { new_session_id } => { @@ -423,7 +491,7 @@ fn render_compact(lines: &mut Vec>, evt: &CompactEvent, mode: Mode ), }; match mode { - Mode::Overview => push_overview_line(lines, &text, kind, ""), + Mode::Overview => push_overview_line(lines, &text, width, kind, ""), _ => push_padded_lines(lines, &text, kind), } }