Skip to content

Commit 0b9ef93

Browse files
authored
fix: properly wrap lines in the Rust TUI (#937)
As discussed on 699ec5a#commitcomment-156776835, to properly support scrolling long content in Ratatui for a sequence of cells, we need to: * take the `Vec<Line>` for each cell * using the wrapping logic we want to use at render time, compute the _effective line count_ using `Paragraph::line_count()` (see `wrapped_line_count_for_cell()` in this PR) * sum up the effective line count to compute the height of the area being scrolled * given a `scroll_position: usize`, index into the list of "effective lines" and accumulate the appropriate `Vec<Line>` for the cells that should be displayed * take that `Vec<Line>` to create a `Paragraph` and use the same line-wrapping policy that was used in `wrapped_line_count_for_cell()` * display the resulting `Paragraph` and use the accounting to display a scrollbar with the appropriate thumb size and offset without having to render the `Vec<Line>` for the full history With this change, lines wrap as I expect and everything appears to redraw correctly as I resize my terminal!
1 parent 34aa199 commit 0b9ef93

File tree

1 file changed

+144
-91
lines changed

1 file changed

+144
-91
lines changed

codex-rs/tui/src/conversation_history_widget.rs

Lines changed: 144 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,21 @@ use ratatui::style::Style;
1111
use ratatui::widgets::*;
1212
use serde_json::Value as JsonValue;
1313
use std::cell::Cell as StdCell;
14+
use std::cell::Cell;
1415
use std::collections::HashMap;
1516
use std::path::PathBuf;
1617

18+
/// A single history entry plus its cached wrapped-line count.
19+
struct Entry {
20+
cell: HistoryCell,
21+
line_count: Cell<usize>,
22+
}
23+
1724
pub struct ConversationHistoryWidget {
18-
history: Vec<HistoryCell>,
25+
entries: Vec<Entry>,
26+
/// The width (in terminal cells/columns) that [`Entry::line_count`] was
27+
/// computed for. When the available width changes we recompute counts.
28+
cached_width: StdCell<u16>,
1929
scroll_position: usize,
2030
/// Number of lines the last time render_ref() was called
2131
num_rendered_lines: StdCell<usize>,
@@ -27,7 +37,8 @@ pub struct ConversationHistoryWidget {
2737
impl ConversationHistoryWidget {
2838
pub fn new() -> Self {
2939
Self {
30-
history: Vec::new(),
40+
entries: Vec::new(),
41+
cached_width: StdCell::new(0),
3142
scroll_position: usize::MAX,
3243
num_rendered_lines: StdCell::new(0),
3344
last_viewport_height: StdCell::new(0),
@@ -73,7 +84,7 @@ impl ConversationHistoryWidget {
7384

7485
fn scroll_up(&mut self, num_lines: u32) {
7586
// If a user is scrolling up from the "stick to bottom" mode, we need to
76-
// map this to a specific scroll position so we can caluate the delta.
87+
// map this to a specific scroll position so we can calculate the delta.
7788
// This requires us to care about how tall the screen is.
7889
if self.scroll_position == usize::MAX {
7990
self.scroll_position = self
@@ -97,9 +108,7 @@ impl ConversationHistoryWidget {
97108
// Compute the maximum explicit scroll offset that still shows a full
98109
// viewport. This mirrors the calculation in `scroll_page_down()` and
99110
// in the render path.
100-
let max_scroll = num_rendered_lines
101-
.saturating_sub(viewport_height)
102-
.saturating_add(1);
111+
let max_scroll = num_rendered_lines.saturating_sub(viewport_height);
103112

104113
let new_pos = self.scroll_position.saturating_add(num_lines as usize);
105114

@@ -144,7 +153,7 @@ impl ConversationHistoryWidget {
144153
// Calculate the maximum explicit scroll offset that is still within
145154
// range. This matches the logic in `scroll_down()` and the render
146155
// method.
147-
let max_scroll = num_lines.saturating_sub(viewport_height).saturating_add(1);
156+
let max_scroll = num_lines.saturating_sub(viewport_height);
148157

149158
// Attempt to move down by a full page.
150159
let new_pos = self.scroll_position.saturating_add(viewport_height);
@@ -166,7 +175,7 @@ impl ConversationHistoryWidget {
166175
/// Note `model` could differ from `config.model` if the agent decided to
167176
/// use a different model than the one requested by the user.
168177
pub fn add_session_info(&mut self, config: &Config, event: SessionConfiguredEvent) {
169-
let is_first_event = self.history.is_empty();
178+
let is_first_event = self.entries.is_empty();
170179
self.add_to_history(HistoryCell::new_session_info(config, event, is_first_event));
171180
}
172181

@@ -216,12 +225,22 @@ impl ConversationHistoryWidget {
216225
}
217226

218227
fn add_to_history(&mut self, cell: HistoryCell) {
219-
self.history.push(cell);
228+
let width = self.cached_width.get();
229+
let count = if width > 0 {
230+
wrapped_line_count_for_cell(&cell, width)
231+
} else {
232+
0
233+
};
234+
235+
self.entries.push(Entry {
236+
cell,
237+
line_count: Cell::new(count),
238+
});
220239
}
221240

222241
/// Remove all history entries and reset scrolling.
223242
pub fn clear(&mut self) {
224-
self.history.clear();
243+
self.entries.clear();
225244
self.scroll_position = usize::MAX;
226245
}
227246

@@ -232,7 +251,9 @@ impl ConversationHistoryWidget {
232251
stderr: String,
233252
exit_code: i32,
234253
) {
235-
for cell in self.history.iter_mut() {
254+
let width = self.cached_width.get();
255+
for entry in self.entries.iter_mut() {
256+
let cell = &mut entry.cell;
236257
if let HistoryCell::ActiveExecCommand {
237258
call_id: history_id,
238259
command,
@@ -250,6 +271,13 @@ impl ConversationHistoryWidget {
250271
duration: start.elapsed(),
251272
},
252273
);
274+
275+
// Update cached line count.
276+
if width > 0 {
277+
entry
278+
.line_count
279+
.set(wrapped_line_count_for_cell(cell, width));
280+
}
253281
break;
254282
}
255283
}
@@ -269,14 +297,15 @@ impl ConversationHistoryWidget {
269297
.unwrap_or_else(|_| serde_json::Value::String("<serialization error>".into()))
270298
});
271299

272-
for cell in self.history.iter_mut() {
300+
let width = self.cached_width.get();
301+
for entry in self.entries.iter_mut() {
273302
if let HistoryCell::ActiveMcpToolCall {
274303
call_id: history_id,
275304
fq_tool_name,
276305
invocation,
277306
start,
278307
..
279-
} = cell
308+
} = &entry.cell
280309
{
281310
if &call_id == history_id {
282311
let completed = HistoryCell::new_completed_mcp_tool_call(
@@ -286,7 +315,14 @@ impl ConversationHistoryWidget {
286315
success,
287316
result_val,
288317
);
289-
*cell = completed;
318+
entry.cell = completed;
319+
320+
if width > 0 {
321+
entry
322+
.line_count
323+
.set(wrapped_line_count_for_cell(&entry.cell, width));
324+
}
325+
290326
break;
291327
}
292328
}
@@ -311,105 +347,102 @@ impl WidgetRef for ConversationHistoryWidget {
311347
.border_type(BorderType::Rounded)
312348
.border_style(border_style);
313349

314-
// ------------------------------------------------------------------
315-
// Build a *window* into the history instead of cloning the entire
316-
// history into a brand‑new Vec every time we are asked to render.
317-
//
318-
// There can be an unbounded number of `Line` objects in the history,
319-
// but the terminal will only ever display `height` of them at once.
320-
// By materialising only the `height` lines that are scrolled into
321-
// view we avoid the potentially expensive clone of the full
322-
// conversation every frame.
323-
// ------------------------------------------------------------------
324-
325350
// Compute the inner area that will be available for the list after
326351
// the surrounding `Block` is drawn.
327352
let inner = block.inner(area);
328353
let viewport_height = inner.height as usize;
329354

330-
// Collect the lines that will actually be visible in the viewport
331-
// while keeping track of the total number of lines so the scrollbar
332-
// stays correct.
333-
let num_lines: usize = self.history.iter().map(|c| c.lines().len()).sum();
355+
// Cache (and if necessary recalculate) the wrapped line counts for
356+
// every [`HistoryCell`] so that our scrolling math accounts for text
357+
// wrapping.
358+
let width = inner.width; // Width of the viewport in terminal cells.
359+
if width == 0 {
360+
return; // Nothing to draw – avoid division by zero.
361+
}
362+
363+
// Recompute cache if the width changed.
364+
let num_lines: usize = if self.cached_width.get() != width {
365+
self.cached_width.set(width);
334366

335-
let max_scroll = num_lines.saturating_sub(viewport_height) + 1;
367+
let mut num_lines: usize = 0;
368+
for entry in &self.entries {
369+
let count = wrapped_line_count_for_cell(&entry.cell, width);
370+
num_lines += count;
371+
entry.line_count.set(count);
372+
}
373+
num_lines
374+
} else {
375+
self.entries.iter().map(|e| e.line_count.get()).sum()
376+
};
377+
378+
// Determine the scroll position. Note the existing value of
379+
// `self.scroll_position` could exceed the maximum scroll offset if the
380+
// user made the window wider since the last render.
381+
let max_scroll = num_lines.saturating_sub(viewport_height);
336382
let scroll_pos = if self.scroll_position == usize::MAX {
337383
max_scroll
338384
} else {
339385
self.scroll_position.min(max_scroll)
340386
};
341387

342-
let mut visible_lines: Vec<Line<'static>> = Vec::with_capacity(viewport_height);
388+
// ------------------------------------------------------------------
389+
// Build a *window* into the history so we only clone the `Line`s that
390+
// may actually be visible in this frame. We still hand the slice off
391+
// to a `Paragraph` with an additional scroll offset to avoid slicing
392+
// inside a wrapped line (we don’t have per-subline granularity).
393+
// ------------------------------------------------------------------
343394

344-
if self.scroll_position == usize::MAX {
345-
// Stick‑to‑bottom mode: walk the history backwards and keep the
346-
// most recent `height` lines. This touches at most `height`
347-
// lines regardless of how large the conversation grows.
348-
'outer_rev: for cell in self.history.iter().rev() {
349-
for line in cell.lines().iter().rev() {
350-
visible_lines.push(line.clone());
351-
if visible_lines.len() == viewport_height {
352-
break 'outer_rev;
353-
}
354-
}
395+
// Find the first entry that intersects the current scroll position.
396+
let mut cumulative = 0usize;
397+
let mut first_idx = 0usize;
398+
for (idx, entry) in self.entries.iter().enumerate() {
399+
let next = cumulative + entry.line_count.get();
400+
if next > scroll_pos {
401+
first_idx = idx;
402+
break;
355403
}
356-
visible_lines.reverse();
357-
} else {
358-
// Arbitrary scroll position. Skip lines until we reach the
359-
// desired offset, then emit the next `height` lines.
360-
let start_line = scroll_pos;
361-
let mut current_index = 0usize;
362-
'outer_fwd: for cell in &self.history {
363-
for line in cell.lines() {
364-
if current_index >= start_line {
365-
visible_lines.push(line.clone());
366-
if visible_lines.len() == viewport_height {
367-
break 'outer_fwd;
368-
}
369-
}
370-
current_index += 1;
371-
}
404+
cumulative = next;
405+
}
406+
407+
let offset_into_first = scroll_pos - cumulative;
408+
409+
// Collect enough raw lines from `first_idx` onward to cover the
410+
// viewport. We may fetch *slightly* more than necessary (whole cells)
411+
// but never the entire history.
412+
let mut collected_wrapped = 0usize;
413+
let mut visible_lines: Vec<Line<'static>> = Vec::new();
414+
415+
for entry in &self.entries[first_idx..] {
416+
visible_lines.extend(entry.cell.lines().iter().cloned());
417+
collected_wrapped += entry.line_count.get();
418+
if collected_wrapped >= offset_into_first + viewport_height {
419+
break;
372420
}
373421
}
374422

375-
// We track the number of lines in the struct so can let the user take over from
376-
// something other than usize::MAX when they start scrolling up. This could be
377-
// removed once we have the vec<Lines> in self.
378-
self.num_rendered_lines.set(num_lines);
379-
self.last_viewport_height.set(viewport_height);
423+
// Build the Paragraph with wrapping enabled so long lines are not
424+
// clipped. Apply vertical scroll so that `offset_into_first` wrapped
425+
// lines are hidden at the top.
426+
let paragraph = Paragraph::new(visible_lines)
427+
.block(block)
428+
.wrap(wrap_cfg())
429+
.scroll((offset_into_first as u16, 0));
380430

381-
// The widget takes care of drawing the `block` and computing its own
382-
// inner area, so we render it over the full `area`.
383-
// We *manually* sliced the set of `visible_lines` to fit within the
384-
// viewport above, so there is no need to ask the `Paragraph` widget
385-
// to apply an additional scroll offset. Doing so would cause the
386-
// content to be shifted *twice* – once by our own logic and then a
387-
// second time by the widget – which manifested as the entire block
388-
// drifting off‑screen when the user attempted to scroll.
389-
390-
// Currently, we do not use the `wrap` method on the `Paragraph` widget
391-
// because it messes up our scrolling math above that assumes each Line
392-
// contributes one line of height to the widget. Admittedly, this is
393-
// bad because users cannot see content that is clipped without
394-
// resizing the terminal.
395-
let paragraph = Paragraph::new(visible_lines).block(block);
396431
paragraph.render(area, buf);
397432

433+
// Draw scrollbar if necessary.
398434
let needs_scrollbar = num_lines > viewport_height;
399435
if needs_scrollbar {
400436
let mut scroll_state = ScrollbarState::default()
401-
// TODO(ragona):
402-
// I don't totally understand this, but it appears to work exactly as expected
403-
// if we set the content length as the lines minus the height. Maybe I was supposed
404-
// to use viewport_content_length or something, but this works and I'm backing away.
437+
// The Scrollbar widget expects the *content* height minus the
438+
// viewport height, mirroring the calculation used previously.
405439
.content_length(num_lines.saturating_sub(viewport_height))
406440
.position(scroll_pos);
407441

408-
// Choose a thumb colour that stands out only when this pane has focus so that the
442+
// Choose a thumb color that stands out only when this pane has focus so that the
409443
// user’s attention is naturally drawn to the active viewport. When unfocused we show
410444
// a low‑contrast thumb so the scrollbar fades into the background without becoming
411445
// invisible.
412-
413446
let thumb_style = if self.has_input_focus {
414447
Style::reset().fg(Color::LightYellow)
415448
} else {
@@ -418,25 +451,25 @@ impl WidgetRef for ConversationHistoryWidget {
418451

419452
StatefulWidget::render(
420453
// By default the Scrollbar widget inherits the style that was already present
421-
// in the underlying buffer cells. That means if a coloured line (for example a
454+
// in the underlying buffer cells. That means if a colored line (for example a
422455
// background task notification that we render in blue) happens to be underneath
423-
// the scrollbar, the track and thumb adopt that colour and the scrollbar appears
424-
// to change colour”. Explicitly setting the *track* and *thumb* styles ensures
456+
// the scrollbar, the track and thumb adopt that color and the scrollbar appears
457+
// to "change color." Explicitly setting the *track* and *thumb* styles ensures
425458
// we always draw the scrollbar with the same palette regardless of what content
426459
// is behind it.
427460
//
428-
// N.B. Only the *foreground* colour matters here because the scrollbar symbols
461+
// N.B. Only the *foreground* color matters here because the scrollbar symbols
429462
// themselves are filled‐in block glyphs that completely overwrite the prior
430-
// character cells. We therefore leave the background at its default value so it
463+
// character cells. We therefore leave the background at its default value so it
431464
// blends nicely with the surrounding `Block`.
432465
Scrollbar::new(ScrollbarOrientation::VerticalRight)
433466
.begin_symbol(Some("↑"))
434467
.end_symbol(Some("↓"))
435468
.begin_style(Style::reset().fg(Color::DarkGray))
436469
.end_style(Style::reset().fg(Color::DarkGray))
437-
// A solid thumb so that we can colour it distinctly from the track.
470+
// A solid thumb so that we can color it distinctly from the track.
438471
.thumb_symbol("█")
439-
// Apply the dynamic thumb colour computed above. We still start from
472+
// Apply the dynamic thumb color computed above. We still start from
440473
// Style::reset() to clear any inherited modifiers.
441474
.thumb_style(thumb_style)
442475
// Thin vertical line for the track.
@@ -447,5 +480,25 @@ impl WidgetRef for ConversationHistoryWidget {
447480
&mut scroll_state,
448481
);
449482
}
483+
484+
// Update auxiliary stats that the scroll handlers rely on.
485+
self.num_rendered_lines.set(num_lines);
486+
self.last_viewport_height.set(viewport_height);
450487
}
451488
}
489+
490+
/// Common [`Wrap`] configuration used for both measurement and rendering so
491+
/// they stay in sync.
492+
#[inline]
493+
const fn wrap_cfg() -> ratatui::widgets::Wrap {
494+
ratatui::widgets::Wrap { trim: false }
495+
}
496+
497+
/// Returns the wrapped line count for `cell` at the given `width` using the
498+
/// same wrapping rules that `ConversationHistoryWidget` uses during
499+
/// rendering.
500+
fn wrapped_line_count_for_cell(cell: &HistoryCell, width: u16) -> usize {
501+
Paragraph::new(cell.lines().clone())
502+
.wrap(wrap_cfg())
503+
.line_count(width)
504+
}

0 commit comments

Comments
 (0)