@@ -11,11 +11,21 @@ use ratatui::style::Style;
11
11
use ratatui:: widgets:: * ;
12
12
use serde_json:: Value as JsonValue ;
13
13
use std:: cell:: Cell as StdCell ;
14
+ use std:: cell:: Cell ;
14
15
use std:: collections:: HashMap ;
15
16
use std:: path:: PathBuf ;
16
17
18
+ /// A single history entry plus its cached wrapped-line count.
19
+ struct Entry {
20
+ cell : HistoryCell ,
21
+ line_count : Cell < usize > ,
22
+ }
23
+
17
24
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 > ,
19
29
scroll_position : usize ,
20
30
/// Number of lines the last time render_ref() was called
21
31
num_rendered_lines : StdCell < usize > ,
@@ -27,7 +37,8 @@ pub struct ConversationHistoryWidget {
27
37
impl ConversationHistoryWidget {
28
38
pub fn new ( ) -> Self {
29
39
Self {
30
- history : Vec :: new ( ) ,
40
+ entries : Vec :: new ( ) ,
41
+ cached_width : StdCell :: new ( 0 ) ,
31
42
scroll_position : usize:: MAX ,
32
43
num_rendered_lines : StdCell :: new ( 0 ) ,
33
44
last_viewport_height : StdCell :: new ( 0 ) ,
@@ -73,7 +84,7 @@ impl ConversationHistoryWidget {
73
84
74
85
fn scroll_up ( & mut self , num_lines : u32 ) {
75
86
// 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.
77
88
// This requires us to care about how tall the screen is.
78
89
if self . scroll_position == usize:: MAX {
79
90
self . scroll_position = self
@@ -97,9 +108,7 @@ impl ConversationHistoryWidget {
97
108
// Compute the maximum explicit scroll offset that still shows a full
98
109
// viewport. This mirrors the calculation in `scroll_page_down()` and
99
110
// 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) ;
103
112
104
113
let new_pos = self . scroll_position . saturating_add ( num_lines as usize ) ;
105
114
@@ -144,7 +153,7 @@ impl ConversationHistoryWidget {
144
153
// Calculate the maximum explicit scroll offset that is still within
145
154
// range. This matches the logic in `scroll_down()` and the render
146
155
// method.
147
- let max_scroll = num_lines. saturating_sub ( viewport_height) . saturating_add ( 1 ) ;
156
+ let max_scroll = num_lines. saturating_sub ( viewport_height) ;
148
157
149
158
// Attempt to move down by a full page.
150
159
let new_pos = self . scroll_position . saturating_add ( viewport_height) ;
@@ -166,7 +175,7 @@ impl ConversationHistoryWidget {
166
175
/// Note `model` could differ from `config.model` if the agent decided to
167
176
/// use a different model than the one requested by the user.
168
177
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 ( ) ;
170
179
self . add_to_history ( HistoryCell :: new_session_info ( config, event, is_first_event) ) ;
171
180
}
172
181
@@ -216,12 +225,22 @@ impl ConversationHistoryWidget {
216
225
}
217
226
218
227
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
+ } ) ;
220
239
}
221
240
222
241
/// Remove all history entries and reset scrolling.
223
242
pub fn clear ( & mut self ) {
224
- self . history . clear ( ) ;
243
+ self . entries . clear ( ) ;
225
244
self . scroll_position = usize:: MAX ;
226
245
}
227
246
@@ -232,7 +251,9 @@ impl ConversationHistoryWidget {
232
251
stderr : String ,
233
252
exit_code : i32 ,
234
253
) {
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 ;
236
257
if let HistoryCell :: ActiveExecCommand {
237
258
call_id : history_id,
238
259
command,
@@ -250,6 +271,13 @@ impl ConversationHistoryWidget {
250
271
duration : start. elapsed ( ) ,
251
272
} ,
252
273
) ;
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
+ }
253
281
break ;
254
282
}
255
283
}
@@ -269,14 +297,15 @@ impl ConversationHistoryWidget {
269
297
. unwrap_or_else ( |_| serde_json:: Value :: String ( "<serialization error>" . into ( ) ) )
270
298
} ) ;
271
299
272
- for cell in self . history . iter_mut ( ) {
300
+ let width = self . cached_width . get ( ) ;
301
+ for entry in self . entries . iter_mut ( ) {
273
302
if let HistoryCell :: ActiveMcpToolCall {
274
303
call_id : history_id,
275
304
fq_tool_name,
276
305
invocation,
277
306
start,
278
307
..
279
- } = cell
308
+ } = & entry . cell
280
309
{
281
310
if & call_id == history_id {
282
311
let completed = HistoryCell :: new_completed_mcp_tool_call (
@@ -286,7 +315,14 @@ impl ConversationHistoryWidget {
286
315
success,
287
316
result_val,
288
317
) ;
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
+
290
326
break ;
291
327
}
292
328
}
@@ -311,105 +347,102 @@ impl WidgetRef for ConversationHistoryWidget {
311
347
. border_type ( BorderType :: Rounded )
312
348
. border_style ( border_style) ;
313
349
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
-
325
350
// Compute the inner area that will be available for the list after
326
351
// the surrounding `Block` is drawn.
327
352
let inner = block. inner ( area) ;
328
353
let viewport_height = inner. height as usize ;
329
354
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) ;
334
366
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) ;
336
382
let scroll_pos = if self . scroll_position == usize:: MAX {
337
383
max_scroll
338
384
} else {
339
385
self . scroll_position . min ( max_scroll)
340
386
} ;
341
387
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
+ // ------------------------------------------------------------------
343
394
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 ;
355
403
}
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 ;
372
420
}
373
421
}
374
422
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 ) ) ;
380
430
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) ;
396
431
paragraph. render ( area, buf) ;
397
432
433
+ // Draw scrollbar if necessary.
398
434
let needs_scrollbar = num_lines > viewport_height;
399
435
if needs_scrollbar {
400
436
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.
405
439
. content_length ( num_lines. saturating_sub ( viewport_height) )
406
440
. position ( scroll_pos) ;
407
441
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
409
443
// user’s attention is naturally drawn to the active viewport. When unfocused we show
410
444
// a low‑contrast thumb so the scrollbar fades into the background without becoming
411
445
// invisible.
412
-
413
446
let thumb_style = if self . has_input_focus {
414
447
Style :: reset ( ) . fg ( Color :: LightYellow )
415
448
} else {
@@ -418,25 +451,25 @@ impl WidgetRef for ConversationHistoryWidget {
418
451
419
452
StatefulWidget :: render (
420
453
// 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
422
455
// 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
425
458
// we always draw the scrollbar with the same palette regardless of what content
426
459
// is behind it.
427
460
//
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
429
462
// 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
431
464
// blends nicely with the surrounding `Block`.
432
465
Scrollbar :: new ( ScrollbarOrientation :: VerticalRight )
433
466
. begin_symbol ( Some ( "↑" ) )
434
467
. end_symbol ( Some ( "↓" ) )
435
468
. begin_style ( Style :: reset ( ) . fg ( Color :: DarkGray ) )
436
469
. 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.
438
471
. 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
440
473
// Style::reset() to clear any inherited modifiers.
441
474
. thumb_style ( thumb_style)
442
475
// Thin vertical line for the track.
@@ -447,5 +480,25 @@ impl WidgetRef for ConversationHistoryWidget {
447
480
& mut scroll_state,
448
481
) ;
449
482
}
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) ;
450
487
}
451
488
}
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