From d3ba0a299abd3d94d23cd786a9a4397a2ea83fa9 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 21 Apr 2026 23:26:34 +0900 Subject: [PATCH] =?UTF-8?q?=E6=94=B9=E8=A1=8C=E3=83=86=E3=82=AD=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=81=AE=E8=A1=8C=E8=A8=88=E7=AE=97=E3=83=BBPadding?= =?UTF-8?q?=E8=A8=AD=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- crates/tui/src/tool.rs | 40 +++----- crates/tui/src/ui.rs | 217 +++++++++++++++++++++++++---------------- 2 files changed, 150 insertions(+), 107 deletions(-) diff --git a/crates/tui/src/tool.rs b/crates/tui/src/tool.rs index 216577df..5d81d465 100644 --- a/crates/tui/src/tool.rs +++ b/crates/tui/src/tool.rs @@ -88,7 +88,6 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend format!("[tool] Read — {count} file{} read", plural(count)) }; lines.push(Line::from(vec![ - Span::raw(" "), Span::styled(header, tool_style), ])); @@ -106,13 +105,13 @@ fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRend let start_idx = paths.len().saturating_sub(limit); for p in &paths[start_idx..] { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled(p.clone(), path_style), ])); } if in_progress && paths.len() > limit { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( format!("… ({} earlier)", paths.len() - limit), Style::default().fg(Color::DarkGray), @@ -162,7 +161,6 @@ fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec Vec Vec shown { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( format!("… +{} more lines", body_lines.len() - shown), Style::default().fg(Color::DarkGray), @@ -232,7 +229,6 @@ fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec Vec Vec> { // Locate the first (and typically only) match. let Some(idx) = content.find(old) else { return vec![Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( "(old_string not found in cached content)".to_owned(), Style::default().fg(Color::DarkGray), @@ -301,25 +297,25 @@ fn build_edit_diff(content: &str, old: &str, new: &str) -> Vec> { for l in &all_lines[ctx_start..line_of_idx] { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled(format!(" {l}"), ctx_style), ])); } for l in old.lines() { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled(format!("-{l}"), minus_style), ])); } for l in new.lines() { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled(format!("+{l}"), plus_style), ])); } for l in &all_lines[line_of_idx + replaced_line_count..ctx_end] { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled(format!(" {l}"), ctx_style), ])); } @@ -347,14 +343,12 @@ fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec Vec shown { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( format!("… +{} more lines", body_lines.len() - shown), Style::default().fg(Color::DarkGray), @@ -408,13 +402,11 @@ fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec> { format!("[tool] {} — {suffix}", tc.name) }; return vec![Line::from(vec![ - Span::raw(" "), Span::styled(label, tool_style), ])]; } let mut lines = vec![Line::from(vec![ - Span::raw(" "), Span::styled( format!("[tool] {} — {}", tc.name, state_suffix(&tc.state)), tool_style, @@ -484,7 +476,7 @@ fn maybe_error_line(lines: &mut Vec>, state: &ToolCallState) { match state { ToolCallState::Error { summary, .. } => { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( format!("error: {}", first_line(summary)), Style::default().fg(Color::Red), @@ -493,7 +485,7 @@ fn maybe_error_line(lines: &mut Vec>, state: &ToolCallState) { } ToolCallState::Incomplete => { lines.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( "(no result before turn ended)".to_owned(), Style::default().fg(Color::Red), @@ -514,13 +506,13 @@ fn emit_capped_lines( let shown = all.len().min(cap); for l in &all[..shown] { out.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled((*l).to_owned(), style), ])); } if all.len() > shown { out.push(Line::from(vec![ - Span::raw(" "), + Span::raw(" "), Span::styled( format!("… +{} more lines", all.len() - shown), Style::default().fg(Color::DarkGray), diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index ec9fd220..d60da2e3 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -18,6 +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 protocol::{Greeting, NotificationLevel}; @@ -92,36 +93,58 @@ pub struct HistoryLayout { } pub fn compute_history(app: &App, width: u16) -> HistoryLayout { - let mut lines: Vec> = Vec::new(); - let mut turn_starts: Vec = Vec::new(); + // Step 1: collect logical lines from each block (unwrapped). + let mut logical: Vec> = Vec::new(); + let mut logical_turn_starts: Vec = Vec::new(); let mut first = true; let mut i = 0; while i < app.blocks.len() { if !first { - lines.push(Line::from("")); + logical.push(Line::from("")); } first = false; let block = &app.blocks[i]; if matches!(block, Block::TurnHeader { .. }) { - turn_starts.push(lines.len()); + logical_turn_starts.push(logical.len()); } - // Tool calls route through the per-tool renderer, which may - // consume multiple adjacent blocks (Read aggregation). if matches!(block, Block::ToolCall(_)) { let out = crate::tool::render_tool(&app.cache, &app.blocks, i, app.mode); - lines.extend(out.lines); + logical.extend(out.lines); i += out.consumed.max(1); continue; } - render_block_into(&mut lines, block, width, app.mode); + render_block_into(&mut logical, block, width, app.mode); i += 1; } + + // Step 2: pre-wrap every logical line to char-based terminal rows so + // scroll math is exact. Track the logical → wrapped mapping so + // turn-start indices get translated into wrapped-row coordinates. + let mut lines: Vec> = Vec::with_capacity(logical.len()); + let mut logical_to_wrapped: Vec = Vec::with_capacity(logical.len() + 1); + for line in logical { + logical_to_wrapped.push(lines.len()); + wrap_line_into(line, width, &mut lines); + } + logical_to_wrapped.push(lines.len()); + + let turn_starts = logical_turn_starts + .into_iter() + .map(|i| logical_to_wrapped.get(i).copied().unwrap_or(lines.len())) + .collect(); + 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 +/// leading-space hacks in the content itself. +const HISTORY_PADDING: u16 = 1; + fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { if area.height == 0 || area.width == 0 { app.scroll.area_height = area.height; @@ -130,16 +153,18 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { app.scroll.turn_starts.clear(); return; } - let width = area.width; - let HistoryLayout { lines, turn_starts } = compute_history(app, width); + let outer_block = UiBlock::default().padding(Padding::horizontal(HISTORY_PADDING)); + let inner = outer_block.inner(area); + if inner.width == 0 || inner.height == 0 { + return; + } - // Cache for key handlers. Computing `tail_top_offset` wrap-aware - // — i.e. in post-wrap terminal rows — is what keeps long CJK - // responses visible at the tail; otherwise the naive - // `total_lines - area_height` formula under-counts rows and the - // viewport anchors too far up. - let tail_top = compute_tail_top_offset(&lines, area.height, width); - app.scroll.area_height = area.height; + let HistoryLayout { lines, turn_starts } = compute_history(app, inner.width); + + // `lines` is already pre-wrapped: 1 entry == 1 terminal row. Scroll + // math degenerates to index arithmetic. + let tail_top = lines.len().saturating_sub(inner.height as usize); + app.scroll.area_height = inner.height; app.scroll.total_lines = lines.len(); app.scroll.tail_top_offset = tail_top; app.scroll.turn_starts = turn_starts; @@ -150,62 +175,97 @@ fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { app.scroll.top_offset = app.scroll.top_offset.min(tail_top); } - let visible = visible_slice(&lines, app.scroll.top_offset, area.height, width); + let end = (app.scroll.top_offset + inner.height as usize).min(lines.len()); + let visible: Vec> = lines[app.scroll.top_offset..end].to_vec(); + + // Pre-wrapped input → render without ratatui's word-wrap (which + // would otherwise re-wrap mid-row at word boundaries and desync the + // height count). The outer Block handles left/right padding + // uniformly for all rows. Paragraph::new(visible) - .wrap(Wrap { trim: false }) + .block(outer_block) .render(area, frame.buffer_mut()); } -/// Smallest top offset that still keeps the last logical line on screen -/// once wrapping is applied. Walks the lines from the tail and counts -/// wrapped rows; returns the first line index that no longer fits. -fn compute_tail_top_offset(lines: &[Line<'_>], area_height: u16, width: u16) -> usize { - if lines.is_empty() || area_height == 0 { - return 0; - } - let mut used: u32 = 0; - let cap = area_height as u32; - for (i, line) in lines.iter().enumerate().rev() { - let h = wrapped_line_height(line, width) as u32; - if used + h > cap { - return i + 1; - } - used += h; - } - 0 -} - -fn visible_slice( - lines: &[Line<'static>], - top_offset: usize, - area_height: u16, - width: u16, -) -> Vec> { - if lines.is_empty() || area_height == 0 { - return Vec::new(); - } - let mut out: Vec> = Vec::new(); - let mut used: u32 = 0; - for line in lines.iter().skip(top_offset) { - let h = wrapped_line_height(line, width) as u32; - if used + h > area_height as u32 { - break; - } - out.push(line.clone()); - used += h; - if used >= area_height as u32 { - break; - } - } - out -} - -fn wrapped_line_height(line: &Line, width: u16) -> u16 { +/// Split one logical line into one-terminal-row `Line`s via char-aware +/// wrapping. Preserves per-span styles and the source line's alignment. +fn wrap_line_into(line: Line<'static>, width: u16, out: &mut Vec>) { if width == 0 { - return 1; + out.push(line); + return; } - let w = line.width() as u16; - if w == 0 { 1 } else { w.div_ceil(width) } + let w = width as usize; + let alignment = line.alignment; + + let mut current: Vec> = Vec::new(); + let mut row_width: usize = 0; + let mut pending = String::new(); + let mut pending_width: usize = 0; + let mut pending_style = Style::default(); + + let commit_pending = |pending: &mut String, + pending_width: &mut usize, + pending_style: Style, + current: &mut Vec>, + row_width: &mut usize| { + if pending.is_empty() { + return; + } + current.push(Span::styled(std::mem::take(pending), pending_style)); + *row_width += *pending_width; + *pending_width = 0; + }; + + let push_row = |current: &mut Vec>, + row_width: &mut usize, + out: &mut Vec>| { + let mut l = Line::from(std::mem::take(current)); + if let Some(a) = alignment { + l = l.alignment(a); + } + out.push(l); + *row_width = 0; + }; + + for span in line.spans { + if !pending.is_empty() && span.style != pending_style { + commit_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut current, + &mut row_width, + ); + } + pending_style = span.style; + + for c in span.content.chars() { + let cw = UnicodeWidthChar::width(c).unwrap_or(0); + if row_width + pending_width + cw > w && (row_width + pending_width) > 0 { + commit_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut current, + &mut row_width, + ); + push_row(&mut current, &mut row_width, out); + } + pending.push(c); + pending_width += cw; + } + + commit_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut current, + &mut row_width, + ); + } + + // Always emit the final row (empty line stays empty). + push_row(&mut current, &mut row_width, out); } fn render_block_into( @@ -285,10 +345,7 @@ fn render_block_into( fn push_padded_lines(lines: &mut Vec>, text: &str, kind: MessageKind) { let style = kind_style(kind); for raw in text.lines() { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled(raw.to_owned(), style), - ])); + lines.push(Line::from(Span::styled(raw.to_owned(), style))); } if text.is_empty() { lines.push(Line::from("")); @@ -311,20 +368,14 @@ fn push_padded_truncated( let all: Vec<&str> = text.lines().collect(); let shown = all.len().min(NORMAL_MAX_LINES); for raw in &all[..shown] { - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled((*raw).to_owned(), style), - ])); + lines.push(Line::from(Span::styled((*raw).to_owned(), style))); } if all.len() > shown { let hidden = all.len() - shown; - lines.push(Line::from(vec![ - Span::raw(" "), - Span::styled( - format!("… +{hidden} more lines"), - Style::default().fg(Color::DarkGray), - ), - ])); + lines.push(Line::from(Span::styled( + format!("… +{hidden} more lines"), + Style::default().fg(Color::DarkGray), + ))); } if text.is_empty() { lines.push(Line::from("")); @@ -342,7 +393,7 @@ fn push_overview_line( let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); let more = text.lines().count().saturating_sub(1); let style = kind_style(kind); - let mut spans = vec![Span::raw(" ")]; + let mut spans: Vec> = Vec::new(); if !prefix.is_empty() { spans.push(Span::styled(prefix.to_owned(), style)); }