fix(tui): streaming remap and resize

This commit is contained in:
Felipe Coury
2026-04-06 13:35:05 -03:00
committed by Felipe Coury
parent 4afa1d0a77
commit 2fd2259286
6 changed files with 176 additions and 22 deletions
+6
View File
@@ -4244,12 +4244,15 @@ impl ChatWidget {
if w == 0 {
None
} else {
// Keep a 1-column minimum for active stream controllers so they can
// continue accepting deltas on ultra-narrow layouts.
Some(crate::width::usable_content_width(w, reserved_cols).unwrap_or(1))
}
})
}
pub(crate) fn on_terminal_resize(&mut self, width: u16) {
let had_rendered_width = self.last_rendered_width.get().is_some();
self.last_rendered_width.set(Some(width as usize));
let stream_width = self.current_stream_width(2);
let plan_stream_width = self.current_stream_width(4);
@@ -4260,6 +4263,9 @@ impl ChatWidget {
controller.set_width(plan_stream_width);
}
self.sync_active_stream_tail();
if !had_rendered_width {
self.request_redraw();
}
}
fn worked_elapsed_from(&mut self, current_elapsed: u64) -> u64 {
@@ -126,6 +126,21 @@ async fn current_stream_width_clamps_to_minimum_when_reserved_columns_exhaust_wi
assert_eq!(chat.current_stream_width(4), Some(1));
}
#[tokio::test]
async fn on_terminal_resize_initial_width_requests_redraw() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
let (draw_tx, mut draw_rx) = tokio::sync::broadcast::channel(8);
chat.frame_requester = FrameRequester::new(draw_tx);
chat.last_rendered_width.set(None);
chat.on_terminal_resize(120);
let draw = tokio::time::timeout(std::time::Duration::from_millis(200), draw_rx.recv())
.await
.expect("timed out waiting for redraw request");
assert!(draw.is_ok(), "expected redraw notification to be sent");
}
#[tokio::test]
async fn add_to_history_does_not_commit_transient_stream_tail_after_controller_clear() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(None).await;
+14 -6
View File
@@ -182,7 +182,7 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd
#[derive(Clone, Copy)]
struct Fence {
marker: char,
marker: u8,
len: usize,
is_markdown: bool,
}
@@ -198,11 +198,15 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd
return None;
}
let trimmed = &without_newline[leading_ws..];
let marker = trimmed.chars().next()?;
if marker != '`' && marker != '~' {
let marker = *trimmed.as_bytes().first()?;
if marker != b'`' && marker != b'~' {
return None;
}
let len = trimmed.chars().take_while(|ch| *ch == marker).count();
let len = trimmed
.as_bytes()
.iter()
.take_while(|byte| **byte == marker)
.count();
if len < 3 {
return None;
}
@@ -227,10 +231,14 @@ fn unwrap_markdown_fences_with_mapping(markdown_source: &str) -> NormalizedMarkd
return false;
}
let trimmed = &without_newline[leading_ws..];
if !trimmed.starts_with(fence.marker) {
if !trimmed.as_bytes().starts_with(&[fence.marker]) {
return false;
}
let len = trimmed.chars().take_while(|ch| *ch == fence.marker).count();
let len = trimmed
.as_bytes()
.iter()
.take_while(|byte| **byte == fence.marker)
.count();
if len < fence.len {
return false;
}
+6 -5
View File
@@ -85,8 +85,6 @@ struct MarkdownStyles {
impl Default for MarkdownStyles {
fn default() -> Self {
use ratatui::style::Stylize;
Self {
h1: Style::new().bold().underlined(),
h2: Style::new().bold(),
@@ -1487,9 +1485,12 @@ where
tag_start += 1;
}
if tag_start < bytes.len()
&& bytes[tag_start].is_ascii_alphabetic()
&& bytes[tag_start + 1..].contains(&b'>')
if bytes
.get(tag_start)
.is_some_and(u8::is_ascii_alphabetic)
&& bytes
.get(tag_start + 1..)
.is_some_and(|suffix| suffix.contains(&b'>'))
{
return true;
}
+70
View File
@@ -584,8 +584,24 @@ impl TranscriptOverlay {
let clamped_end = range.end.min(self.cells.len());
let clamped_start = range.start.min(clamped_end);
if clamped_start < clamped_end {
let removed = clamped_end - clamped_start;
if let Some(highlight_cell) = self.highlight_cell.as_mut()
&& *highlight_cell >= clamped_start
{
if *highlight_cell < clamped_end {
*highlight_cell = clamped_start;
} else {
*highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1));
}
}
self.cells
.splice(clamped_start..clamped_end, std::iter::once(consolidated));
if self
.highlight_cell
.is_some_and(|highlight_cell| highlight_cell >= self.cells.len())
{
self.highlight_cell = None;
}
self.rebuild_renderables();
}
if follow_bottom {
@@ -1117,6 +1133,60 @@ mod tests {
assert_eq!(overlay.view.scroll_offset, 0);
}
#[test]
fn transcript_overlay_consolidation_remaps_highlight_inside_range() {
let mut overlay = TranscriptOverlay::new(
(0..6)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
overlay.set_highlight_cell(Some(3));
overlay.consolidate_cells(
2..5,
Arc::new(TestCell {
lines: vec![Line::from("consolidated")],
}),
);
assert_eq!(
overlay.highlight_cell,
Some(2),
"highlight inside consolidated range should point to replacement cell",
);
}
#[test]
fn transcript_overlay_consolidation_remaps_highlight_after_range() {
let mut overlay = TranscriptOverlay::new(
(0..7)
.map(|i| {
Arc::new(TestCell {
lines: vec![Line::from(format!("line{i}"))],
}) as Arc<dyn HistoryCell>
})
.collect(),
);
overlay.set_highlight_cell(Some(6));
overlay.consolidate_cells(
2..5,
Arc::new(TestCell {
lines: vec![Line::from("consolidated")],
}),
);
assert_eq!(
overlay.highlight_cell,
Some(4),
"highlight after consolidated range should shift left by removed cells",
);
}
#[test]
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title
+65 -11
View File
@@ -30,6 +30,7 @@
//!
//! - `emitted_stable_len <= enqueued_stable_len <= rendered_lines.len()`.
//! - `rendered_line_end_source_bytes.len() == rendered_lines.len()`.
//! - `rendered_line_end_source_totals.len() == rendered_lines.len()`.
//! - `raw_source` is append-only until `reset()`; never modified mid-stream.
//! - Tail starts exactly at `enqueued_stable_len`.
//! - During confirmed table streaming, only lines from the table header onward
@@ -51,6 +52,7 @@ use std::time::Instant;
use crate::table_detect::is_table_delimiter_line;
use crate::table_detect::is_table_header_line;
use crate::table_detect::parse_table_segments;
use std::collections::HashMap;
use super::StreamState;
@@ -77,6 +79,8 @@ struct StreamCore {
rendered_lines: Vec<Line<'static>>,
/// Per-rendered-line source byte offsets (raw source coordinates).
rendered_line_end_source_bytes: Vec<usize>,
/// Per-rendered-line total count of lines sharing that source byte offset.
rendered_line_end_source_totals: Vec<usize>,
/// Lines enqueued into the commit-animation queue.
enqueued_stable_len: usize,
/// Lines actually emitted to scrollback.
@@ -109,6 +113,7 @@ impl StreamCore {
raw_source: String::new(),
rendered_lines: Vec::new(),
rendered_line_end_source_bytes: Vec::new(),
rendered_line_end_source_totals: Vec::new(),
enqueued_stable_len: 0,
emitted_stable_len: 0,
cwd: cwd.to_path_buf(),
@@ -227,6 +232,7 @@ impl StreamCore {
self.raw_source.clear();
self.rendered_lines.clear();
self.rendered_line_end_source_bytes.clear();
self.rendered_line_end_source_totals.clear();
self.enqueued_stable_len = 0;
self.emitted_stable_len = 0;
self.emitted_stable_source_bytes = 0;
@@ -245,10 +251,23 @@ impl StreamCore {
);
self.rendered_lines = rendered.lines;
self.rendered_line_end_source_bytes = rendered.line_end_source_bytes;
let mut source_totals: HashMap<usize, usize> = HashMap::new();
for source_end in &self.rendered_line_end_source_bytes {
*source_totals.entry(*source_end).or_insert(0) += 1;
}
self.rendered_line_end_source_totals = self
.rendered_line_end_source_bytes
.iter()
.map(|source_end| source_totals.get(source_end).copied().unwrap_or(1))
.collect();
debug_assert_eq!(
self.rendered_line_end_source_bytes.len(),
self.rendered_lines.len()
);
debug_assert_eq!(
self.rendered_line_end_source_totals.len(),
self.rendered_lines.len()
);
}
/// Advance `enqueued_stable_len` toward the target stable boundary and enqueue any
@@ -383,16 +402,19 @@ impl StreamCore {
}
let end = emitted_end.min(self.rendered_line_end_source_bytes.len());
for line_end_source in &self.rendered_line_end_source_bytes[emitted_start..end] {
for (offset, line_end_source) in self.rendered_line_end_source_bytes[emitted_start..end]
.iter()
.enumerate()
{
let line_index = emitted_start + offset;
if *line_end_source > self.emitted_stable_source_bytes {
self.emitted_stable_source_bytes = *line_end_source;
self.emitted_stable_lines_at_source_byte = 1;
self.emitted_stable_lines_at_source_byte_total = self
.rendered_line_end_source_bytes
.iter()
.filter(|source| **source == *line_end_source)
.count()
.max(1);
.rendered_line_end_source_totals
.get(line_index)
.copied()
.unwrap_or(1);
} else if *line_end_source == self.emitted_stable_source_bytes {
self.emitted_stable_lines_at_source_byte += 1;
}
@@ -447,11 +469,10 @@ impl StreamCore {
.take_while(|line_end| **line_end == boundary)
.count();
let total_at_boundary = self
.rendered_line_end_source_bytes
.iter()
.filter(|line_end| **line_end == boundary)
.count()
.max(1);
.rendered_line_end_source_totals
.get(emitted_len - 1)
.copied()
.unwrap_or(1);
self.emitted_stable_source_bytes = boundary;
self.emitted_stable_lines_at_source_byte = emitted_at_boundary;
@@ -1203,6 +1224,39 @@ mod tests {
);
}
#[test]
fn remap_emitted_len_preserves_source_boundary_tie_breaks() {
let cwd = test_cwd();
let mut core = StreamCore::new(Some(80), &cwd);
core.raw_source = "line\nline\nline\n".to_string();
core.rendered_lines = vec![
Line::from("row-0"),
Line::from("row-1"),
Line::from("row-2"),
Line::from("row-3"),
Line::from("row-4"),
];
core.rendered_line_end_source_bytes = vec![5, 5, 9, 14, 14];
core.rendered_line_end_source_totals = vec![2, 2, 1, 2, 2];
core.emitted_stable_len = 3;
core.emitted_stable_source_bytes = 5;
core.emitted_stable_lines_at_source_byte_total = 2;
core.emitted_stable_lines_at_source_byte = 1;
assert_eq!(
core.remap_emitted_len_from_source_boundary(),
1,
"expected one emitted line at source boundary when one was previously emitted",
);
core.emitted_stable_lines_at_source_byte = 2;
assert_eq!(
core.remap_emitted_len_from_source_boundary(),
2,
"expected all lines at source boundary when boundary was fully emitted",
);
}
#[tokio::test]
async fn controller_loose_vs_tight_with_commit_ticks_matches_full() {
let mut ctrl = stream_controller(/*width*/ None);