@@ -11,11 +11,21 @@ use ratatui::style::Style;
1111use ratatui:: widgets:: * ;
1212use serde_json:: Value as JsonValue ;
1313use std:: cell:: Cell as StdCell ;
14+ use std:: cell:: Cell ;
1415use std:: collections:: HashMap ;
1516use 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+
1724pub 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 {
2737impl 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