diff --git a/crates/tui/src/tui/ui.rs b/crates/tui/src/tui/ui.rs index 5308b16c..ff5be381 100644 --- a/crates/tui/src/tui/ui.rs +++ b/crates/tui/src/tui/ui.rs @@ -931,8 +931,37 @@ async fn run_event_loop( } if let Event::Resize(width, height) = evt { + tracing::debug!(width, height, "Event::Resize received; clearing terminal"); + // Coalesce queued resize events so we only act on the final + // size. Rapid drag-resizes can produce many intermediate + // events; processing each one with its own clear+redraw + // amplifies the artifact window. Drain the queue here, keep + // only the final dimensions, and issue a single clear+draw. + let mut final_w = width; + let mut final_h = height; + while event::poll(Duration::from_millis(0)).unwrap_or(false) { + match event::read() { + Ok(Event::Resize(w, h)) => { + final_w = w; + final_h = h; + } + Ok(other) => { + tracing::debug!( + ?other, + "non-resize event during resize coalesce; dropping" + ); + break; + } + Err(_) => break, + } + } terminal.clear()?; - app.handle_resize(width, height); + app.handle_resize(final_w, final_h); + // Repaint immediately. Without this, the cleared screen can + // sit blank until the next event triggers another draw, + // which presents as a flicker or stale frame to the user. + terminal.draw(|f| render(f, app))?; + app.needs_redraw = false; continue; } @@ -3569,8 +3598,22 @@ pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String { if UnicodeWidthStr::width(text) <= max_width { return text.to_string(); } + // For very small budgets, take chars until we exceed the *display* width. + // The previous implementation counted codepoints, which overran the + // budget for any double-width grapheme and contributed to mid-character + // sidebar artifacts on resize (issue #65). if max_width <= 3 { - return text.chars().take(max_width).collect(); + let mut out = String::new(); + let mut width = 0usize; + for ch in text.chars() { + let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + ch_width > max_width { + break; + } + out.push(ch); + width += ch_width; + } + return out; } let mut out = String::new(); diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 6b8175d1..d272807a 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -1790,3 +1790,30 @@ fn second_thinking_block_appends_new_entry_in_same_active_cell() { "the group still hasn't flushed — no prose yet" ); } + +/// Regression for issue #65: `truncate_line_to_width` with a tiny budget +/// must respect display widths, not codepoint counts. The old branch counted +/// chars and overran the budget for any double-width grapheme, which +/// contributed to mid-character sidebar artifacts on resize. +#[test] +fn truncate_line_to_width_respects_display_width_for_tiny_budgets() { + use unicode_width::UnicodeWidthStr; + + let trimmed = truncate_line_to_width("Agents", 3); + assert_eq!(trimmed, "Age"); + assert!(UnicodeWidthStr::width(trimmed.as_str()) <= 3); + + let trimmed_cjk = truncate_line_to_width("中文测试", 3); + assert!( + UnicodeWidthStr::width(trimmed_cjk.as_str()) <= 3, + "trimmed CJK width {} exceeded budget 3 (got {trimmed_cjk:?})", + UnicodeWidthStr::width(trimmed_cjk.as_str()), + ); + + assert_eq!(truncate_line_to_width("anything", 0), ""); + assert_eq!(truncate_line_to_width("hi", 10), "hi"); + + let trimmed_long = truncate_line_to_width("a long sidebar label", 10); + assert!(trimmed_long.ends_with("...")); + assert!(UnicodeWidthStr::width(trimmed_long.as_str()) <= 10); +} diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 825fc568..3b370b67 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1653,4 +1653,88 @@ mod tests { "scrollbar should be visible for a long history" ); } + + /// Regression for issue #65: after `App::handle_resize`, the chat widget + /// must produce a clean render at the new width — no panic, and a + /// populated history must yield non-empty rendered cells. Cycling + /// through several widths (shrinks and grows) flushes any cached layout + /// that fails to invalidate on resize. + #[test] + fn chat_widget_renders_cleanly_after_resize_cycle() { + let mut app = create_test_app(); + for i in 0..40 { + app.add_message(HistoryCell::User { + content: format!("user message {i} with enough text to wrap at 30 columns easily"), + }); + } + + let widths_to_cycle = [120u16, 80, 40, 60, 100, 30]; + let height: u16 = 20; + for width in widths_to_cycle { + app.handle_resize(width, height); + let area = Rect { + x: 0, + y: 0, + width, + height, + }; + let mut buf = Buffer::empty(area); + let widget = ChatWidget::new(&mut app, area); + widget.render(area, &mut buf); + + let mut non_empty = 0usize; + for y in 0..height { + for x in 0..width { + let sym = buf[(x, y)].symbol(); + if sym != " " && !sym.is_empty() { + non_empty += 1; + } + } + } + assert!( + non_empty > 0, + "render at {width}x{height} produced an empty buffer after resize" + ); + } + } + + /// Regression for issue #65: the transcript view cache must invalidate + /// when width changes, so the same `App.history` re-wraps at the new + /// width on the very next `ChatWidget::new` call. + #[test] + fn transcript_cache_invalidates_on_width_change() { + let mut app = create_test_app(); + for i in 0..10 { + app.add_message(HistoryCell::User { + content: format!("a fairly long user message number {i} that needs to wrap"), + }); + } + + let area_wide = Rect { + x: 0, + y: 0, + width: 120, + height: 20, + }; + let area_narrow = Rect { + x: 0, + y: 0, + width: 30, + height: 20, + }; + let mut buf_wide = Buffer::empty(area_wide); + let widget_wide = ChatWidget::new(&mut app, area_wide); + widget_wide.render(area_wide, &mut buf_wide); + let wide_total_lines = app.transcript_cache.total_lines(); + + let mut buf_narrow = Buffer::empty(area_narrow); + let widget_narrow = ChatWidget::new(&mut app, area_narrow); + widget_narrow.render(area_narrow, &mut buf_narrow); + let narrow_total_lines = app.transcript_cache.total_lines(); + + assert!( + narrow_total_lines > wide_total_lines, + "narrow render should produce more wrapped lines (got {narrow_total_lines}, wide={wide_total_lines})" + ); + } }