fix: correctly wrap history items (#1685)
The overall idea here is: skip ratatui for writing into scrollback, because its primitives are wrong. We want to render full lines of text, that will be wrapped natively by the terminal, and which we never plan to update using ratatui (so the `Buffer` struct is overhead and in fact an inhibition). Instead, we use ANSI scrolling regions (link reference doc to come). Essentially, we: 1. Define a scrolling region that extends from the top of the prompt area all the way to the top of scrollback 2. Scroll that region up by N < (screen_height - viewport_height) lines, in this PR N=1 3. Put our cursor at the top of the newly empty region 4. Print out our new text like normal The terminal interactions here (write_spans and its dependencies) are mostly extracted from ratatui.
This commit is contained in:
@@ -35,8 +35,9 @@ lazy_static = "1"
|
||||
mcp-types = { path = "../mcp-types" }
|
||||
path-clean = "1.0.1"
|
||||
ratatui = { version = "0.29.0", features = [
|
||||
"unstable-widget-ref",
|
||||
"scrolling-regions",
|
||||
"unstable-rendered-line-info",
|
||||
"unstable-widget-ref",
|
||||
] }
|
||||
ratatui-image = "8.0.0"
|
||||
regex-lite = "0.1"
|
||||
|
||||
@@ -1,178 +1,162 @@
|
||||
use std::io;
|
||||
use std::io::Write;
|
||||
|
||||
use crate::tui;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Style;
|
||||
use crossterm::queue;
|
||||
use crossterm::style::Color as CColor;
|
||||
use crossterm::style::Colors;
|
||||
use crossterm::style::Print;
|
||||
use crossterm::style::SetAttribute;
|
||||
use crossterm::style::SetBackgroundColor;
|
||||
use crossterm::style::SetColors;
|
||||
use crossterm::style::SetForegroundColor;
|
||||
use ratatui::layout::Position;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
/// Insert a batch of history lines into the terminal scrollback above the
|
||||
/// inline viewport.
|
||||
///
|
||||
/// The incoming `lines` are the logical lines supplied by the
|
||||
/// `ConversationHistory`. They may contain embedded newlines and arbitrary
|
||||
/// runs of whitespace inside individual [`Span`]s. All of that must be
|
||||
/// normalised before writing to the backing terminal buffer because the
|
||||
/// ratatui [`Paragraph`] widget does not perform soft‑wrapping when used in
|
||||
/// conjunction with [`Terminal::insert_before`].
|
||||
///
|
||||
/// This function performs a minimal wrapping / normalisation pass:
|
||||
///
|
||||
/// * A terminal width is determined via `Terminal::size()` (falling back to
|
||||
/// 80 columns if the size probe fails).
|
||||
/// * Each logical line is broken into words and whitespace. Consecutive
|
||||
/// whitespace is collapsed to a single space; leading whitespace is
|
||||
/// discarded.
|
||||
/// * Words that do not fit on the current line cause a soft wrap. Extremely
|
||||
/// long words (longer than the terminal width) are split character by
|
||||
/// character so they still populate the display instead of overflowing the
|
||||
/// line.
|
||||
/// * Explicit `\n` characters inside a span force a hard line break.
|
||||
/// * Empty lines (including a trailing newline at the end of the batch) are
|
||||
/// preserved so vertical spacing remains faithful to the logical history.
|
||||
///
|
||||
/// Finally the physical lines are rendered directly into the terminal's
|
||||
/// scrollback region using [`Terminal::insert_before`]. Any backend error is
|
||||
/// ignored: failing to insert history is non‑fatal and a subsequent redraw
|
||||
/// will eventually repaint a consistent view.
|
||||
fn display_width(s: &str) -> usize {
|
||||
s.chars()
|
||||
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
|
||||
.sum()
|
||||
}
|
||||
|
||||
struct LineBuilder {
|
||||
term_width: usize,
|
||||
spans: Vec<Span<'static>>,
|
||||
width: usize,
|
||||
}
|
||||
|
||||
impl LineBuilder {
|
||||
fn new(term_width: usize) -> Self {
|
||||
Self {
|
||||
term_width,
|
||||
spans: Vec::new(),
|
||||
width: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_line(&mut self, out: &mut Vec<Line<'static>>) {
|
||||
out.push(Line::from(std::mem::take(&mut self.spans)));
|
||||
self.width = 0;
|
||||
}
|
||||
|
||||
fn push_segment(&mut self, text: String, style: Style) {
|
||||
self.width += display_width(&text);
|
||||
self.spans.push(Span::styled(text, style));
|
||||
}
|
||||
|
||||
fn push_word(&mut self, word: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
|
||||
if word.is_empty() {
|
||||
return;
|
||||
}
|
||||
let w_len = display_width(word);
|
||||
if self.width > 0 && self.width + w_len > self.term_width {
|
||||
self.flush_line(out);
|
||||
}
|
||||
if w_len > self.term_width && self.width == 0 {
|
||||
// Split an overlong word across multiple lines.
|
||||
let mut cur = String::new();
|
||||
let mut cur_w = 0;
|
||||
for ch in word.chars() {
|
||||
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if cur_w + ch_w > self.term_width && cur_w > 0 {
|
||||
self.push_segment(cur.clone(), style);
|
||||
self.flush_line(out);
|
||||
cur.clear();
|
||||
cur_w = 0;
|
||||
}
|
||||
cur.push(ch);
|
||||
cur_w += ch_w;
|
||||
}
|
||||
if !cur.is_empty() {
|
||||
self.push_segment(cur, style);
|
||||
}
|
||||
} else {
|
||||
self.push_segment(word.clone(), style);
|
||||
}
|
||||
word.clear();
|
||||
}
|
||||
|
||||
fn consume_whitespace(&mut self, ws: &mut String, style: Style, out: &mut Vec<Line<'static>>) {
|
||||
if ws.is_empty() {
|
||||
return;
|
||||
}
|
||||
let space_w = display_width(ws);
|
||||
if self.width > 0 && self.width + space_w > self.term_width {
|
||||
self.flush_line(out);
|
||||
}
|
||||
if self.width > 0 {
|
||||
self.push_segment(" ".to_string(), style);
|
||||
}
|
||||
ws.clear();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec<Line<'static>>) {
|
||||
let term_width = terminal.size().map(|a| a.width).unwrap_or(80) as usize;
|
||||
let mut physical: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
for logical in lines.into_iter() {
|
||||
if logical.spans.is_empty() {
|
||||
physical.push(logical);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut builder = LineBuilder::new(term_width);
|
||||
let mut buf_space = String::new();
|
||||
|
||||
for span in logical.spans.into_iter() {
|
||||
let style = span.style;
|
||||
let mut buf_word = String::new();
|
||||
|
||||
for ch in span.content.chars() {
|
||||
if ch == '\n' {
|
||||
builder.push_word(&mut buf_word, style, &mut physical);
|
||||
buf_space.clear();
|
||||
builder.flush_line(&mut physical);
|
||||
continue;
|
||||
}
|
||||
if ch.is_whitespace() {
|
||||
builder.push_word(&mut buf_word, style, &mut physical);
|
||||
buf_space.push(ch);
|
||||
} else {
|
||||
builder.consume_whitespace(&mut buf_space, style, &mut physical);
|
||||
buf_word.push(ch);
|
||||
}
|
||||
if builder.width >= term_width {
|
||||
builder.flush_line(&mut physical);
|
||||
}
|
||||
}
|
||||
builder.push_word(&mut buf_word, style, &mut physical);
|
||||
// whitespace intentionally left to allow collapsing across spans
|
||||
}
|
||||
if !builder.spans.is_empty() {
|
||||
physical.push(Line::from(std::mem::take(&mut builder.spans)));
|
||||
let screen_height = terminal
|
||||
.backend()
|
||||
.size()
|
||||
.map(|s| s.height)
|
||||
.unwrap_or(0xffffu16);
|
||||
let mut area = terminal.get_frame().area();
|
||||
// We scroll up one line at a time because we can't position the cursor
|
||||
// above the top of the screen. i.e. if
|
||||
// lines.len() > screen_height - area.top()
|
||||
// we would need to print the first line above the top of the screen, which
|
||||
// can't be done.
|
||||
for line in lines.into_iter() {
|
||||
// 1. Scroll everything above the viewport up by one line
|
||||
if area.bottom() >= screen_height {
|
||||
let top = area.top();
|
||||
terminal.backend_mut().scroll_region_up(0..top, 1).ok();
|
||||
// 2. Move the cursor to the blank line
|
||||
terminal.set_cursor_position(Position::new(0, top - 1)).ok();
|
||||
} else {
|
||||
// Preserve explicit blank line (e.g. due to a trailing newline).
|
||||
physical.push(Line::from(Vec::<Span<'static>>::new()));
|
||||
// If the viewport isn't at the bottom of the screen, scroll down instead
|
||||
terminal
|
||||
.backend_mut()
|
||||
.scroll_region_down(area.top()..area.bottom() + 1, 1)
|
||||
.ok();
|
||||
terminal
|
||||
.set_cursor_position(Position::new(0, area.top()))
|
||||
.ok();
|
||||
area.y += 1;
|
||||
}
|
||||
// 3. Write the line
|
||||
write_spans(&mut std::io::stdout(), line.iter()).ok();
|
||||
}
|
||||
terminal.set_viewport_area(area);
|
||||
}
|
||||
|
||||
struct ModifierDiff {
|
||||
pub from: Modifier,
|
||||
pub to: Modifier,
|
||||
}
|
||||
|
||||
impl ModifierDiff {
|
||||
fn queue<W>(self, mut w: W) -> io::Result<()>
|
||||
where
|
||||
W: io::Write,
|
||||
{
|
||||
use crossterm::style::Attribute as CAttribute;
|
||||
let removed = self.from - self.to;
|
||||
if removed.contains(Modifier::REVERSED) {
|
||||
queue!(w, SetAttribute(CAttribute::NoReverse))?;
|
||||
}
|
||||
if removed.contains(Modifier::BOLD) {
|
||||
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
||||
if self.to.contains(Modifier::DIM) {
|
||||
queue!(w, SetAttribute(CAttribute::Dim))?;
|
||||
}
|
||||
}
|
||||
if removed.contains(Modifier::ITALIC) {
|
||||
queue!(w, SetAttribute(CAttribute::NoItalic))?;
|
||||
}
|
||||
if removed.contains(Modifier::UNDERLINED) {
|
||||
queue!(w, SetAttribute(CAttribute::NoUnderline))?;
|
||||
}
|
||||
if removed.contains(Modifier::DIM) {
|
||||
queue!(w, SetAttribute(CAttribute::NormalIntensity))?;
|
||||
}
|
||||
if removed.contains(Modifier::CROSSED_OUT) {
|
||||
queue!(w, SetAttribute(CAttribute::NotCrossedOut))?;
|
||||
}
|
||||
if removed.contains(Modifier::SLOW_BLINK) || removed.contains(Modifier::RAPID_BLINK) {
|
||||
queue!(w, SetAttribute(CAttribute::NoBlink))?;
|
||||
}
|
||||
|
||||
let added = self.to - self.from;
|
||||
if added.contains(Modifier::REVERSED) {
|
||||
queue!(w, SetAttribute(CAttribute::Reverse))?;
|
||||
}
|
||||
if added.contains(Modifier::BOLD) {
|
||||
queue!(w, SetAttribute(CAttribute::Bold))?;
|
||||
}
|
||||
if added.contains(Modifier::ITALIC) {
|
||||
queue!(w, SetAttribute(CAttribute::Italic))?;
|
||||
}
|
||||
if added.contains(Modifier::UNDERLINED) {
|
||||
queue!(w, SetAttribute(CAttribute::Underlined))?;
|
||||
}
|
||||
if added.contains(Modifier::DIM) {
|
||||
queue!(w, SetAttribute(CAttribute::Dim))?;
|
||||
}
|
||||
if added.contains(Modifier::CROSSED_OUT) {
|
||||
queue!(w, SetAttribute(CAttribute::CrossedOut))?;
|
||||
}
|
||||
if added.contains(Modifier::SLOW_BLINK) {
|
||||
queue!(w, SetAttribute(CAttribute::SlowBlink))?;
|
||||
}
|
||||
if added.contains(Modifier::RAPID_BLINK) {
|
||||
queue!(w, SetAttribute(CAttribute::RapidBlink))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn write_spans<'a, I>(mut writer: &mut impl Write, content: I) -> io::Result<()>
|
||||
where
|
||||
I: Iterator<Item = &'a Span<'a>>,
|
||||
{
|
||||
let mut fg = Color::Reset;
|
||||
let mut bg = Color::Reset;
|
||||
let mut modifier = Modifier::empty();
|
||||
for span in content {
|
||||
let mut next_modifier = modifier;
|
||||
next_modifier.insert(span.style.add_modifier);
|
||||
next_modifier.remove(span.style.sub_modifier);
|
||||
if next_modifier != modifier {
|
||||
let diff = ModifierDiff {
|
||||
from: modifier,
|
||||
to: next_modifier,
|
||||
};
|
||||
diff.queue(&mut writer)?;
|
||||
modifier = next_modifier;
|
||||
}
|
||||
let next_fg = span.style.fg.unwrap_or(Color::Reset);
|
||||
let next_bg = span.style.bg.unwrap_or(Color::Reset);
|
||||
if next_fg != fg || next_bg != bg {
|
||||
queue!(
|
||||
writer,
|
||||
SetColors(Colors::new(next_fg.into(), next_bg.into()))
|
||||
)?;
|
||||
fg = next_fg;
|
||||
bg = next_bg;
|
||||
}
|
||||
|
||||
queue!(writer, Print(span.content.clone()))?;
|
||||
}
|
||||
|
||||
let total = physical.len() as u16;
|
||||
terminal
|
||||
.insert_before(total, |buf| {
|
||||
let width = buf.area.width;
|
||||
for (i, line) in physical.into_iter().enumerate() {
|
||||
let area = Rect {
|
||||
x: 0,
|
||||
y: i as u16,
|
||||
width,
|
||||
height: 1,
|
||||
};
|
||||
Paragraph::new(line).render(area, buf);
|
||||
}
|
||||
})
|
||||
.ok();
|
||||
queue!(
|
||||
writer,
|
||||
SetForegroundColor(CColor::Reset),
|
||||
SetBackgroundColor(CColor::Reset),
|
||||
SetAttribute(crossterm::style::Attribute::Reset),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user