feat(tui): render streaming markdown tables

Adds width-aware markdown table rendering and keeps the newest streamed block as an active tail. Unstable table rows redraw in place instead of being committed into scrollback.

This covers assistant and proposed-plan streams without terminal-specific scrollback mutation.
This commit is contained in:
Felipe Coury
2026-05-05 00:54:20 -03:00
parent 3ad7cf0993
commit 1f7d2a7fe7
13 changed files with 1689 additions and 19 deletions
+66
View File
@@ -744,6 +744,7 @@ pub(crate) struct ChatWidget {
codex_op_target: CodexOpTarget,
bottom_pane: BottomPane,
active_cell: Option<Box<dyn HistoryCell>>,
active_cell_is_stream_tail: bool,
/// Monotonic-ish counter used to invalidate transcript overlay caching.
///
/// The transcript overlay appends a cached "live tail" for the current active cell. Most
@@ -1730,6 +1731,7 @@ impl ChatWidget {
fn flush_answer_stream_with_separator(&mut self) {
let had_stream_controller = self.stream_controller.is_some();
self.clear_stream_active_tail();
if let Some(mut controller) = self.stream_controller.take() {
let (cell, source) = controller.finalize();
if let Some(cell) = cell {
@@ -2336,6 +2338,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_plan_stream_active_tail();
self.request_redraw();
}
@@ -2358,6 +2361,7 @@ impl ChatWidget {
self.saw_plan_item_this_turn = true;
let (finalized_streamed_cell, consolidated_plan_source) =
if let Some(mut controller) = self.plan_stream_controller.take() {
self.clear_stream_active_tail();
controller.finalize()
} else {
(None, None)
@@ -2443,6 +2447,7 @@ impl ChatWidget {
self.plan_item_active = false;
self.adaptive_chunking.reset();
self.plan_stream_controller = None;
self.clear_stream_active_tail();
self.turn_runtime_metrics = RuntimeMetricsSummary::default();
self.session_telemetry.reset_runtime_metrics();
self.bottom_pane.clear_quit_shortcut_hint();
@@ -2499,6 +2504,7 @@ impl ChatWidget {
// If a stream is currently active, finalize it.
self.flush_answer_stream_with_separator();
if let Some(mut controller) = self.plan_stream_controller.take() {
self.clear_stream_active_tail();
let (cell, source) = controller.finalize();
if let Some(cell) = cell {
self.add_boxed_history(cell);
@@ -2997,6 +3003,7 @@ impl ChatWidget {
self.adaptive_chunking.reset();
self.stream_controller = None;
self.plan_stream_controller = None;
self.clear_stream_active_tail();
self.pending_status_indicator_restore = false;
self.request_status_line_branch_refresh();
self.request_status_line_git_summary_refresh();
@@ -4267,6 +4274,7 @@ impl ChatWidget {
self.bottom_pane.hide_status_indicator();
self.add_boxed_history(cell);
}
self.sync_active_stream_tail();
if outcome.has_controller && outcome.all_idle {
self.maybe_restore_status_indicator_after_stream_idle();
@@ -4338,6 +4346,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_agent_stream_active_tail();
self.request_redraw();
}
@@ -4888,6 +4897,7 @@ impl ChatWidget {
skills: None,
}),
active_cell,
active_cell_is_stream_tail: false,
active_cell_revision: 0,
config,
effective_service_tier,
@@ -5489,11 +5499,62 @@ impl ChatWidget {
fn flush_active_cell(&mut self) {
if let Some(active) = self.active_cell.take() {
if self.active_cell_is_stream_tail {
self.active_cell_is_stream_tail = false;
self.bump_active_cell_revision();
return;
}
self.needs_final_message_separator = true;
self.app_event_tx.send(AppEvent::InsertHistoryCell(active));
}
}
fn clear_stream_active_tail(&mut self) {
if self.active_cell_is_stream_tail {
self.active_cell = None;
self.active_cell_is_stream_tail = false;
self.bump_active_cell_revision();
}
}
fn set_stream_active_tail(&mut self, cell: Option<Box<dyn HistoryCell>>) {
match cell {
Some(cell) => {
if !self.active_cell_is_stream_tail {
self.flush_active_cell();
}
self.active_cell = Some(cell);
self.active_cell_is_stream_tail = true;
self.bump_active_cell_revision();
}
None => self.clear_stream_active_tail(),
}
}
fn sync_agent_stream_active_tail(&mut self) {
let cell = self
.stream_controller
.as_ref()
.and_then(StreamController::active_tail_cell);
self.set_stream_active_tail(cell);
}
fn sync_plan_stream_active_tail(&mut self) {
let cell = self
.plan_stream_controller
.as_ref()
.and_then(PlanStreamController::active_tail_cell);
self.set_stream_active_tail(cell);
}
fn sync_active_stream_tail(&mut self) {
if self.stream_controller.is_some() {
self.sync_agent_stream_active_tail();
} else {
self.sync_plan_stream_active_tail();
}
}
pub(crate) fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
self.add_boxed_history(Box::new(cell));
}
@@ -6716,6 +6777,10 @@ impl ChatWidget {
/// Mark the active cell as failed (✗) and flush it into history.
fn finalize_active_cell_as_failed(&mut self) {
if self.active_cell_is_stream_tail {
self.clear_stream_active_tail();
return;
}
if let Some(mut cell) = self.active_cell.take() {
// Insert finalized cell into history and keep grouping consistent.
if let Some(exec) = cell.as_any_mut().downcast_mut::<ExecCell>() {
@@ -10291,6 +10356,7 @@ impl ChatWidget {
if let Some(controller) = self.plan_stream_controller.as_mut() {
controller.set_width(plan_stream_width);
}
self.sync_active_stream_tail();
if !had_rendered_width {
self.request_redraw();
}
@@ -188,6 +188,7 @@ pub(super) async fn make_chatwidget_manual(
codex_op_target: super::CodexOpTarget::Direct(op_tx),
bottom_pane: bottom,
active_cell: None,
active_cell_is_stream_tail: false,
active_cell_revision: 0,
config: cfg,
effective_service_tier,
@@ -829,6 +829,24 @@ async fn plan_implementation_popup_skips_replayed_turn_complete() {
);
}
#[tokio::test]
async fn streamed_plan_table_uses_active_tail() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
let plan_mask = collaboration_modes::mask_for_kind(chat.model_catalog.as_ref(), ModeKind::Plan)
.expect("expected plan collaboration mask");
chat.set_collaboration_mask(plan_mask);
chat.on_task_started();
chat.on_plan_delta("| Step | Owner |\n| --- | --- |\n| Test | Codex |\n".to_string());
let active = active_blob(&chat);
assert!(
active.contains("Proposed Plan") && active.contains("│ Test │ Codex │"),
"expected streamed plan table preview in active tail, got {active:?}"
);
}
#[tokio::test]
async fn plan_implementation_popup_shows_once_when_replay_precedes_live_turn_complete() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
@@ -2953,6 +2953,42 @@ printf 'fenced within fenced\n'
);
}
#[tokio::test]
async fn streamed_markdown_table_uses_active_tail_until_stable() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_turn_started(&mut chat, "turn-table");
handle_agent_message_delta(
&mut chat,
"| Tool | Use |\n| --- | --- |\n| rg | find |\n".to_string(),
);
let active = active_blob(&chat);
assert!(
active.contains("┌──────┬──────┐") && active.contains("│ rg │ find │"),
"expected streamed table preview in active tail, got {active:?}"
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"the table tail should not be committed to history yet"
);
handle_agent_message_delta(&mut chat, "\nNext paragraph.\n".to_string());
for _ in 0..8 {
chat.on_commit_tick();
}
let history = drain_insert_history(&mut rx);
let committed = history
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
committed.contains("┌──────┬──────┐") && committed.contains("│ rg │ find │"),
"expected table to commit after a following block arrived, got {committed:?}"
);
}
#[tokio::test]
async fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
+152 -10
View File
@@ -27,8 +27,16 @@ use regex_lite::Regex;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use unicode_width::UnicodeWidthStr;
use url::Url;
mod table;
mod table_cell;
mod table_state;
use table::normalize_table_boundaries;
use table::render_table_lines;
use table_state::TableState;
struct MarkdownStyles {
h1: Style,
h2: Style,
@@ -108,7 +116,9 @@ pub(crate) fn render_markdown_text_with_width_and_cwd(
) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
options.insert(Options::ENABLE_TABLES);
let normalized_input = normalize_table_boundaries(input);
let parser = Parser::new_ext(normalized_input.as_ref(), options);
let mut w = Writer::new(parser, width, cwd);
w.run();
w.text
@@ -172,6 +182,7 @@ where
current_subsequent_indent: Vec<Span<'static>>,
current_line_style: Style,
current_line_in_code_block: bool,
table: Option<TableState>,
}
impl<'a, I> Writer<'a, I>
@@ -204,6 +215,7 @@ where
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
table: None,
}
}
@@ -277,12 +289,11 @@ where
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
Tag::Table(_) => self.start_table(),
Tag::TableHead | Tag::TableRow => self.start_table_row(),
Tag::TableCell => self.start_table_cell(),
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
| Tag::TableHead
| Tag::TableRow
| Tag::TableCell
| Tag::Image { .. }
| Tag::MetadataBlock(_) => {}
}
@@ -305,13 +316,18 @@ where
self.pending_marker_line = false;
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
TagEnd::Link => self.pop_link(),
TagEnd::Link => {
if self.table.is_some() {
self.pop_table_link();
} else {
self.pop_link();
}
}
TagEnd::Table => self.end_table(),
TagEnd::TableHead | TagEnd::TableRow => self.end_table_row(),
TagEnd::TableCell => self.end_table_cell(),
TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::Image
| TagEnd::MetadataBlock(_) => {}
}
@@ -373,7 +389,69 @@ where
self.needs_newline = true;
}
fn start_table(&mut self) {
if self.needs_newline {
self.push_blank_line();
self.needs_newline = false;
}
self.flush_current_line();
self.table = Some(TableState::default());
}
fn end_table(&mut self) {
let Some(table) = self.table.take() else {
return;
};
let prefix_width = self
.prefix_spans(/*pending_marker_line*/ false)
.iter()
.map(|span| span.content.width())
.sum();
let table_width = self
.wrap_width
.map(|width| width.saturating_sub(prefix_width));
for line in render_table_lines(&table.rows, table_width) {
self.push_line(line);
self.flush_current_line();
}
self.needs_newline = true;
}
fn start_table_row(&mut self) {
if let Some(table) = self.table.as_mut() {
table.start_row();
}
}
fn end_table_row(&mut self) {
if let Some(table) = self.table.as_mut() {
table.end_row();
}
}
fn start_table_cell(&mut self) {
if let Some(table) = self.table.as_mut() {
table.start_cell();
}
}
fn end_table_cell(&mut self) {
if let Some(table) = self.table.as_mut() {
table.end_cell();
}
}
fn text(&mut self, text: CowStr<'a>) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(&text, style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -427,6 +505,15 @@ where
}
fn code(&mut self, code: CowStr<'a>) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
if let Some(table) = self.table.as_mut() {
table.push_text(&code, self.styles.code);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -440,6 +527,16 @@ where
}
fn html(&mut self, html: CowStr<'a>, inline: bool) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_html(&html, style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -460,6 +557,16 @@ where
}
fn hard_break(&mut self) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(" ", style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -468,6 +575,16 @@ where
}
fn soft_break(&mut self) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(" ", style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -640,6 +757,31 @@ where
}
}
fn pop_table_link(&mut self) {
let Some(link) = self.link.take() else {
return;
};
let Some(table) = self.table.as_mut() else {
return;
};
if link.show_destination {
table.push_span(" (".into());
table.push_span(Span::styled(link.destination, self.styles.link));
table.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
// Local file links are rendered as code-like path text so the transcript shows the
// resolved target instead of arbitrary caller-provided label text.
let style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.code);
table.push_span(Span::styled(local_target_display, style));
}
}
fn suppressing_local_link_label(&self) -> bool {
self.link
.as_ref()
+947
View File
@@ -0,0 +1,947 @@
use super::table_cell::TableCell;
use std::borrow::Cow;
use std::ops::Range;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
#[derive(Debug)]
struct TableLayoutCandidate {
column_widths: Vec<usize>,
padding: usize,
hard_wrap: bool,
}
#[derive(Debug)]
struct TableMetrics {
average_body_row_height: f64,
max_body_row_height: usize,
hard_wrap_count: usize,
}
pub(super) fn render_table_lines(
rows: &[Vec<TableCell>],
width: Option<usize>,
) -> Vec<Line<'static>> {
if rows.is_empty() {
return Vec::new();
}
let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
if column_count == 0 {
return Vec::new();
}
let normalized_rows = normalize_table_rows(rows, column_count);
let terminal_width = width.unwrap_or(usize::MAX / 4);
let safety_columns = if has_width_risk_chars(&normalized_rows) {
2
} else {
1
};
let available_width = terminal_width.saturating_sub(safety_columns).max(1);
let widths = desired_column_widths(&normalized_rows, column_count);
match choose_table_layout(&normalized_rows, &widths, available_width, column_count) {
Some(candidate) => render_box_table(
&normalized_rows,
&candidate.column_widths,
candidate.padding,
candidate.hard_wrap,
),
None => render_vertical_table(&normalized_rows, available_width),
}
}
pub(super) fn normalize_table_boundaries(input: &str) -> Cow<'_, str> {
if !input.contains('|') {
return Cow::Borrowed(input);
}
let lines = input.split_inclusive('\n').collect::<Vec<_>>();
let mut out = String::with_capacity(input.len());
let mut changed = false;
let mut index = 0;
let mut code_fence: Option<(char, usize)> = None;
while index < lines.len() {
if let Some(fence) = code_fence {
out.push_str(lines[index]);
if is_closing_code_fence(lines[index], fence) {
code_fence = None;
}
index += 1;
} else if let Some(fence) = opening_code_fence(lines[index]) {
code_fence = Some(fence);
out.push_str(lines[index]);
index += 1;
} else if is_indented_code_line(lines[index]) {
out.push_str(lines[index]);
index += 1;
} else if index + 1 < lines.len()
&& is_table_row_source(lines[index])
&& is_table_delimiter_source(lines[index + 1])
{
push_table_row_source(&mut out, lines[index], &mut changed);
out.push_str(lines[index + 1]);
index += 2;
while index < lines.len() && is_table_row_source(lines[index]) {
push_table_row_source(&mut out, lines[index], &mut changed);
index += 1;
}
if index < lines.len() && !lines[index].trim().is_empty() {
out.push('\n');
changed = true;
}
} else {
out.push_str(lines[index]);
index += 1;
}
}
if changed {
Cow::Owned(out)
} else {
Cow::Borrowed(input)
}
}
fn opening_code_fence(line: &str) -> Option<(char, usize)> {
let trimmed = strip_fence_indent(line)?;
let mut chars = trimmed.chars();
let marker = chars.next()?;
if marker != '`' && marker != '~' {
return None;
}
let marker_count = 1 + chars.take_while(|ch| *ch == marker).count();
(marker_count >= 3).then_some((marker, marker_count))
}
fn is_closing_code_fence(line: &str, (marker, opening_count): (char, usize)) -> bool {
let Some(trimmed) = strip_fence_indent(line) else {
return false;
};
let marker_count = trimmed.chars().take_while(|ch| *ch == marker).count();
marker_count >= opening_count
&& trimmed[marker.len_utf8() * marker_count..]
.trim()
.is_empty()
}
fn strip_fence_indent(line: &str) -> Option<&str> {
let mut spaces = 0usize;
for (index, ch) in line.char_indices() {
if ch != ' ' {
return (spaces <= 3).then_some(&line[index..]);
}
spaces += 1;
if spaces > 3 {
return None;
}
}
Some("")
}
fn is_indented_code_line(line: &str) -> bool {
line.starts_with(" ") || line.starts_with('\t')
}
fn is_table_row_source(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && table_cell_sources(trimmed).len() > 1
}
fn is_table_delimiter_source(line: &str) -> bool {
let mut cells = table_cell_sources(line.trim());
if cells.first().is_some_and(|cell| cell.trim().is_empty()) {
cells.remove(0);
}
if cells.last().is_some_and(|cell| cell.trim().is_empty()) {
cells.pop();
}
if cells.is_empty() {
return false;
}
cells.iter().all(|cell| {
let cell = cell.trim();
let dash_count = cell.chars().filter(|ch| *ch == '-').count();
dash_count >= 3 && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' '))
})
}
fn push_table_row_source(out: &mut String, line: &str, changed: &mut bool) {
match escape_inline_code_pipes_in_table_row(line) {
Cow::Borrowed(line) => out.push_str(line),
Cow::Owned(line) => {
*changed = true;
out.push_str(&line);
}
}
}
fn escape_inline_code_pipes_in_table_row(line: &str) -> Cow<'_, str> {
if !line.contains('`') {
return Cow::Borrowed(line);
}
let code_ranges = inline_code_span_ranges(line);
if code_ranges.is_empty() {
return Cow::Borrowed(line);
}
let mut out: Option<String> = None;
let mut last = 0;
for (index, ch) in line.char_indices() {
if ch == '|'
&& is_index_in_ranges(index, &code_ranges)
&& !is_backslash_escaped(line.as_bytes(), index)
{
let out = out.get_or_insert_with(|| String::with_capacity(line.len() + 1));
out.push_str(&line[last..index]);
out.push('\\');
last = index;
}
}
match out {
Some(mut out) => {
out.push_str(&line[last..]);
Cow::Owned(out)
}
None => Cow::Borrowed(line),
}
}
fn table_cell_sources(line: &str) -> Vec<&str> {
let code_ranges = inline_code_span_ranges(line);
let mut cells = Vec::new();
let mut cell_start = 0;
for (index, ch) in line.char_indices() {
if ch == '|'
&& !is_index_in_ranges(index, &code_ranges)
&& !is_backslash_escaped(line.as_bytes(), index)
{
cells.push(&line[cell_start..index]);
cell_start = index + ch.len_utf8();
}
}
cells.push(&line[cell_start..]);
cells
}
fn inline_code_span_ranges(line: &str) -> Vec<Range<usize>> {
let bytes = line.as_bytes();
let mut ranges = Vec::new();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'\\' => {
index = next_char_index(line, index + 1).unwrap_or(bytes.len());
}
b'`' => {
let delimiter_len = repeated_ascii_len(bytes, index, /*needle*/ b'`');
if let Some(closing_start) =
find_closing_backtick_run(line, index + delimiter_len, delimiter_len)
{
let closing_end = closing_start + delimiter_len;
ranges.push(index..closing_end);
index = closing_end;
} else {
index += delimiter_len;
}
}
_ => {
index = next_char_index(line, index).unwrap_or(bytes.len());
}
}
}
ranges
}
fn find_closing_backtick_run(line: &str, start: usize, delimiter_len: usize) -> Option<usize> {
let bytes = line.as_bytes();
let mut index = start;
while index < bytes.len() {
if bytes[index] == b'`' {
let run_len = repeated_ascii_len(bytes, index, /*needle*/ b'`');
if run_len == delimiter_len {
return Some(index);
}
index += run_len;
} else {
index = next_char_index(line, index).unwrap_or(bytes.len());
}
}
None
}
fn repeated_ascii_len(bytes: &[u8], start: usize, needle: u8) -> usize {
bytes[start..]
.iter()
.take_while(|byte| **byte == needle)
.count()
}
fn next_char_index(line: &str, index: usize) -> Option<usize> {
line.get(index..)?
.chars()
.next()
.map(|ch| index + ch.len_utf8())
}
fn is_index_in_ranges(index: usize, ranges: &[Range<usize>]) -> bool {
ranges.iter().any(|range| range.contains(&index))
}
fn is_backslash_escaped(bytes: &[u8], index: usize) -> bool {
let mut backslashes = 0;
let mut cursor = index;
while cursor > 0 && bytes[cursor - 1] == b'\\' {
backslashes += 1;
cursor -= 1;
}
backslashes % 2 == 1
}
fn normalize_table_rows(rows: &[Vec<TableCell>], column_count: usize) -> Vec<Vec<TableCell>> {
rows.iter()
.map(|row| {
let mut normalized = row.clone();
normalized.resize(column_count, TableCell::default());
normalized
})
.collect()
}
fn desired_column_widths(rows: &[Vec<TableCell>], column_count: usize) -> Vec<usize> {
let mut widths = vec![3; column_count];
for row in rows {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(cell.width());
}
}
widths
}
fn choose_table_layout(
rows: &[Vec<TableCell>],
desired_widths: &[usize],
available_width: usize,
column_count: usize,
) -> Option<TableLayoutCandidate> {
let normal = allocate_table_widths(
rows,
desired_widths,
available_width,
column_count,
/*padding*/ 1,
)
.and_then(|widths| {
build_table_candidate(rows, widths, available_width, /*padding*/ 1)
});
if normal.is_some() {
return normal;
}
allocate_table_widths(
rows,
desired_widths,
available_width,
column_count,
/*padding*/ 0,
)
.and_then(|widths| {
build_table_candidate(rows, widths, available_width, /*padding*/ 0)
})
}
fn build_table_candidate(
rows: &[Vec<TableCell>],
column_widths: Vec<usize>,
available_width: usize,
padding: usize,
) -> Option<TableLayoutCandidate> {
let metrics = table_metrics(rows, &column_widths, /*hard_wrap*/ false);
let needs_hard_wrap = metrics.hard_wrap_count > 0 || metrics.max_body_row_height > 12;
let (hard_wrap, metrics) = if needs_hard_wrap {
(
true,
table_metrics(rows, &column_widths, /*hard_wrap*/ true),
)
} else {
(false, metrics)
};
if should_render_vertical(rows, &column_widths, available_width, padding, &metrics) {
return None;
}
Some(TableLayoutCandidate {
column_widths,
padding,
hard_wrap,
})
}
fn allocate_table_widths(
rows: &[Vec<TableCell>],
desired_widths: &[usize],
available_width: usize,
column_count: usize,
padding: usize,
) -> Option<Vec<usize>> {
let border_width = column_count + 1;
let padding_width = padding * 2 * column_count;
let available_content_width = available_width.checked_sub(border_width + padding_width)?;
let min_total = 3 * column_count;
if available_content_width < min_total {
return None;
}
let mut widths = vec![3; column_count];
let mut remaining = available_content_width - min_total;
let basic_targets = desired_widths
.iter()
.enumerate()
.map(|(index, desired)| {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(normalized_header)
.unwrap_or_default();
let is_icon_column = matches!(header.as_str(), "icon" | "emoji");
let compact_target = if is_index_column(rows, index) || is_icon_column {
4
} else {
6
};
(*desired).min(compact_target).max(3)
})
.collect::<Vec<_>>();
grow_columns_to_targets(&mut widths, &mut remaining, &basic_targets);
let content_targets = desired_widths
.iter()
.enumerate()
.map(|(index, desired)| {
if is_content_heavy_column(rows, index) {
(*desired).min(24).max(widths[index])
} else {
widths[index]
}
})
.collect::<Vec<_>>();
grow_columns_to_targets(&mut widths, &mut remaining, &content_targets);
grow_columns_to_targets(&mut widths, &mut remaining, desired_widths);
Some(widths)
}
fn grow_columns_to_targets(widths: &mut [usize], remaining: &mut usize, targets: &[usize]) {
while *remaining > 0 {
let mut changed = false;
for index in 0..widths.len() {
if *remaining == 0 {
break;
}
if widths[index] < targets[index] {
widths[index] += 1;
*remaining -= 1;
changed = true;
}
}
if !changed {
break;
}
}
}
fn render_box_table(
rows: &[Vec<TableCell>],
column_widths: &[usize],
padding: usize,
hard_wrap: bool,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
out.push(Line::from(border_line(
"┌",
"┬",
"┐",
column_widths,
padding,
)));
for (index, row) in rows.iter().enumerate() {
out.extend(render_table_row(row, column_widths, padding, hard_wrap));
if index == 0 {
out.push(Line::from(border_line(
"├",
"┼",
"┤",
column_widths,
padding,
)));
}
}
out.push(Line::from(border_line(
"└",
"┴",
"┘",
column_widths,
padding,
)));
out
}
fn render_table_row(
row: &[TableCell],
column_widths: &[usize],
padding: usize,
hard_wrap: bool,
) -> Vec<Line<'static>> {
let wrapped_cells = row
.iter()
.zip(column_widths)
.map(|(cell, width)| wrap_table_cell(cell, *width, hard_wrap))
.collect::<Vec<_>>();
let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);
let mut out = Vec::with_capacity(row_height);
for line_index in 0..row_height {
let mut spans = vec![Span::from("│")];
for (cell_lines, width) in wrapped_cells.iter().zip(column_widths) {
let content = cell_lines.get(line_index);
push_padding(&mut spans, padding);
if let Some(content) = content {
spans.extend(content.spans.iter().cloned());
push_padding(&mut spans, width.saturating_sub(content.width()));
} else {
push_padding(&mut spans, *width);
}
push_padding(&mut spans, padding);
spans.push(Span::from("│"));
}
out.push(Line::from(spans));
}
out
}
fn push_padding(spans: &mut Vec<Span<'static>>, width: usize) {
if width > 0 {
spans.push(Span::from(" ".repeat(width)));
}
}
fn border_line(
left: &str,
separator: &str,
right: &str,
column_widths: &[usize],
padding: usize,
) -> String {
let cell_segments = column_widths
.iter()
.map(|width| "─".repeat(width + padding * 2))
.collect::<Vec<_>>();
format!("{left}{}{right}", cell_segments.join(separator))
}
fn wrap_table_cell(cell: &TableCell, width: usize, hard_wrap: bool) -> Vec<Line<'static>> {
if cell.lines().is_empty() {
return vec![Line::default()];
}
let mut lines = Vec::new();
let options = RtOptions::new(width)
.break_words(hard_wrap)
.word_separator(textwrap::WordSeparator::AsciiSpace)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
for segment in cell.lines() {
if segment.width() == 0 {
lines.push(Line::default());
continue;
}
let wrapped = word_wrap_line(segment, options.clone())
.into_iter()
.map(|line| line_to_static(&line))
.collect::<Vec<_>>();
if wrapped.is_empty() {
lines.push(Line::default());
} else {
lines.extend(wrapped);
}
}
if lines.is_empty() {
vec![Line::default()]
} else {
lines
}
}
fn table_metrics(
rows: &[Vec<TableCell>],
column_widths: &[usize],
hard_wrap: bool,
) -> TableMetrics {
let mut row_heights = Vec::with_capacity(rows.len());
let mut hard_wrap_count = 0usize;
for row in rows {
let row_height = row
.iter()
.zip(column_widths)
.map(|(cell, width)| {
if cell_needs_hard_wrap(cell, *width) {
hard_wrap_count += 1;
}
wrap_table_cell(cell, *width, hard_wrap).len()
})
.max()
.unwrap_or(1);
row_heights.push(row_height);
}
let body_heights = row_heights.iter().skip(1).copied().collect::<Vec<_>>();
let body_row_count = body_heights.len();
let max_body_row_height = body_heights.iter().copied().max().unwrap_or(0);
let average_body_row_height = if body_row_count == 0 {
0.0
} else {
body_heights.iter().sum::<usize>() as f64 / body_row_count as f64
};
TableMetrics {
average_body_row_height,
max_body_row_height,
hard_wrap_count,
}
}
fn cell_needs_hard_wrap(cell: &TableCell, width: usize) -> bool {
cell.plain_text()
.split('\n')
.flat_map(str::split_whitespace)
.any(|token| token.width() > width)
}
fn should_render_vertical(
rows: &[Vec<TableCell>],
column_widths: &[usize],
available_width: usize,
padding: usize,
metrics: &TableMetrics,
) -> bool {
let column_count = column_widths.len();
let body_rows = rows.len().saturating_sub(1);
if metrics.max_body_row_height > 24
|| (body_rows >= 10 && metrics.average_body_row_height > 6.0)
|| (body_rows >= 24 && metrics.average_body_row_height > 4.0)
{
return true;
}
let non_index_columns = (0..column_count)
.filter(|index| !is_index_column(rows, *index))
.count();
let starved_non_index_columns = column_widths
.iter()
.enumerate()
.filter(|(index, width)| !is_index_column(rows, *index) && **width <= 3)
.count();
if column_count >= 4
&& body_rows >= 12
&& non_index_columns > 0
&& starved_non_index_columns == non_index_columns
{
return true;
}
has_width_risk_chars(rows)
&& available_width <= 24
&& table_total_width(column_widths, padding) >= available_width
}
fn table_total_width(column_widths: &[usize], padding: usize) -> usize {
column_widths.iter().sum::<usize>()
+ column_widths.len()
+ 1
+ padding * 2 * column_widths.len()
}
fn is_index_column(rows: &[Vec<TableCell>], index: usize) -> bool {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(normalized_header)
.unwrap_or_default();
if matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row") {
return true;
}
rows.iter()
.skip(1)
.filter_map(|row| row.get(index))
.map(TableCell::trimmed_plain_text)
.filter(|cell| !cell.is_empty())
.all(|cell| cell.chars().all(|ch| ch.is_ascii_digit()))
}
fn is_content_heavy_column(rows: &[Vec<TableCell>], index: usize) -> bool {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(normalized_header)
.unwrap_or_default();
if [
"link",
"url",
"code",
"sample",
"content",
"description",
"summary",
"expectation",
"notes",
]
.iter()
.any(|needle| header.contains(needle))
{
return true;
}
rows.iter()
.skip(1)
.filter_map(|row| row.get(index))
.any(|cell| is_url_or_code_like(cell) || cell.width() > 24)
}
fn is_url_or_code_like(cell: &TableCell) -> bool {
let text = cell.plain_text();
text.contains("://")
|| text.contains("::")
|| text.contains("=>")
|| text.contains("->")
|| text.contains('`')
|| text.contains('{')
|| text.contains('}')
|| text.contains('(')
|| text.contains(')')
}
fn has_width_risk_chars(rows: &[Vec<TableCell>]) -> bool {
rows.iter().flatten().any(|cell| {
cell.plain_text().chars().any(|ch| {
matches!(ch, '\u{fe0f}' | '\u{200d}') || ('\u{1f300}'..='\u{1faff}').contains(&ch)
})
})
}
fn normalized_header(header: &TableCell) -> String {
header.trimmed_plain_text().to_ascii_lowercase()
}
fn render_vertical_table(rows: &[Vec<TableCell>], available_width: usize) -> Vec<Line<'static>> {
let Some((headers, body_rows)) = rows.split_first() else {
return Vec::new();
};
let included_columns = included_vertical_columns(headers, body_rows);
if included_columns.is_empty() {
return Vec::new();
}
let max_header_width = included_columns
.iter()
.map(|index| vertical_label(headers, *index).width())
.max()
.unwrap_or(4);
let max_value_width = body_rows
.iter()
.flat_map(|row| included_columns.iter().filter_map(|index| row.get(*index)))
.flat_map(|cell| {
cell.plain_text()
.lines()
.map(str::to_string)
.collect::<Vec<_>>()
})
.map(|line| line.width())
.max()
.unwrap_or(1);
let available_content_width = available_width.saturating_sub(7);
let (label_width, value_width) = if available_content_width < 6 {
let label_width = available_content_width.saturating_div(2).max(1);
(
label_width,
available_content_width.saturating_sub(label_width).max(1),
)
} else {
let min_label_width = 3;
let min_value_width = 3;
let desired_label_width = max_header_width.min(20).max(min_label_width);
let label_width =
desired_label_width.min(available_content_width.saturating_sub(min_value_width));
let desired_value_width = max_value_width.max(min_value_width);
let value_width = desired_value_width
.min(available_content_width.saturating_sub(label_width))
.max(min_value_width);
(label_width, value_width)
};
let mut out = Vec::new();
out.push(Line::from(border_line(
"┌",
"┬",
"┐",
&[label_width, value_width],
/*padding*/ 1,
)));
for (row_index, row) in body_rows.iter().enumerate() {
if row_index > 0 {
out.push(Line::from(border_line(
"├",
"┼",
"┤",
&[label_width, value_width],
/*padding*/ 1,
)));
}
for index in &included_columns {
let label = truncate_to_width(&vertical_label(headers, *index), label_width);
let empty_cell = TableCell::default();
let cell = row.get(*index).unwrap_or(&empty_cell);
let wrapped = if cell.is_blank() {
vec![Line::from("—")]
} else {
wrap_table_cell(cell, value_width, /*hard_wrap*/ true)
};
let wrapped = if wrapped.is_empty() {
vec![Line::default()]
} else {
wrapped
};
for (line_index, value_line) in wrapped.iter().enumerate() {
let label = if line_index == 0 { label.as_str() } else { "" };
let label_padding = label_width.saturating_sub(label.width());
let value_padding = value_width.saturating_sub(value_line.width());
let mut spans = vec![Span::from("│ ")];
push_padding(&mut spans, label_padding);
spans.push(Span::from(label.to_string()));
spans.push(Span::from(" │ "));
spans.extend(value_line.spans.iter().cloned());
push_padding(&mut spans, value_padding);
spans.push(Span::from(" │"));
out.push(Line::from(spans));
}
}
}
out.push(Line::from(border_line(
"└",
"┴",
"┘",
&[label_width, value_width],
/*padding*/ 1,
)));
out
}
fn included_vertical_columns(headers: &[TableCell], body_rows: &[Vec<TableCell>]) -> Vec<usize> {
let first_column_titles_rows = headers
.first()
.map(normalized_header)
.is_some_and(|header| matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row"))
&& headers.len() > 1;
(0..headers.len())
.filter(|index| !(first_column_titles_rows && *index == 0))
.filter(|index| {
!headers[*index].is_blank()
|| body_rows
.iter()
.any(|row| row.get(*index).is_some_and(|cell| !cell.is_blank()))
})
.collect()
}
fn vertical_label(headers: &[TableCell], index: usize) -> String {
headers
.get(index)
.map(TableCell::trimmed_plain_text)
.filter(|header| !header.is_empty())
.unwrap_or_else(|| "Column".to_string())
}
fn truncate_to_width(input: &str, max_width: usize) -> String {
if input.width() <= max_width {
return input.to_string();
}
if max_width == 0 {
return String::new();
}
if max_width == 1 {
return "…".to_string();
}
let mut out = String::new();
let target = max_width - 1;
let mut width = 0usize;
for ch in input.chars() {
let ch_width = ch.to_string().width();
if width + ch_width > target {
break;
}
out.push(ch);
width += ch_width;
}
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn table_cell_sources_ignore_escaped_and_inline_code_pipes() {
let cells = table_cell_sources("| a | `b | c` | d \\| e |");
assert_eq!(
cells.iter().map(|cell| cell.trim()).collect::<Vec<_>>(),
vec!["", "a", "`b | c`", "d \\| e", ""]
);
}
#[test]
fn table_row_normalization_escapes_raw_pipes_inside_inline_code() {
assert_eq!(
escape_inline_code_pipes_in_table_row("| A | `a | b` | ``x ` | y`` |\n").as_ref(),
"| A | `a \\| b` | ``x ` \\| y`` |\n"
);
}
#[test]
fn table_row_normalization_preserves_existing_escaped_pipes() {
assert_eq!(
escape_inline_code_pipes_in_table_row("| A | `a \\| b` |\n").as_ref(),
"| A | `a \\| b` |\n"
);
}
#[test]
fn table_row_normalization_ignores_unclosed_inline_code() {
assert_eq!(
escape_inline_code_pipes_in_table_row("| A | `a | b |\n").as_ref(),
"| A | `a | b |\n"
);
}
}
@@ -0,0 +1,77 @@
use std::borrow::Cow;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct TableCell {
lines: Vec<Line<'static>>,
}
impl TableCell {
pub(super) fn push_text(&mut self, text: &str, style: Style) {
self.push_span(Span::styled(text.to_string(), style));
}
pub(super) fn push_span(&mut self, span: Span<'static>) {
let content = span.content.to_string();
for (index, segment) in content.split('\n').enumerate() {
if index > 0 {
self.push_line_break();
}
if segment.is_empty() {
self.ensure_line();
continue;
}
let mut segment_span = span.clone();
segment_span.content = Cow::Owned(segment.to_string());
self.ensure_line().push_span(segment_span);
}
}
pub(super) fn push_line_break(&mut self) {
self.lines.push(Line::default());
}
pub(super) fn plain_text(&self) -> String {
self.lines
.iter()
.map(line_plain_text)
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn trimmed_plain_text(&self) -> String {
self.plain_text().trim().to_string()
}
pub(super) fn width(&self) -> usize {
self.lines.iter().map(Line::width).max().unwrap_or(0)
}
pub(super) fn is_blank(&self) -> bool {
self.lines
.iter()
.all(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
}
pub(super) fn lines(&self) -> &[Line<'static>] {
&self.lines
}
fn ensure_line(&mut self) -> &mut Line<'static> {
if self.lines.is_empty() {
self.lines.push(Line::default());
}
let last_index = self.lines.len() - 1;
&mut self.lines[last_index]
}
}
fn line_plain_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
@@ -0,0 +1,61 @@
use ratatui::style::Style;
use ratatui::text::Span;
use super::table_cell::TableCell;
#[derive(Debug, Default)]
pub(super) struct TableState {
pub(super) rows: Vec<Vec<TableCell>>,
current_row: Vec<TableCell>,
current_cell: TableCell,
in_cell: bool,
}
impl TableState {
pub(super) fn start_row(&mut self) {
self.current_row.clear();
}
pub(super) fn start_cell(&mut self) {
self.current_cell = TableCell::default();
self.in_cell = true;
}
pub(super) fn push_text(&mut self, text: &str, style: Style) {
if self.in_cell {
self.current_cell.push_text(text, style);
}
}
pub(super) fn push_span(&mut self, span: Span<'static>) {
if self.in_cell {
self.current_cell.push_span(span);
}
}
pub(super) fn push_html(&mut self, html: &str, style: Style) {
let trimmed = html.trim();
if matches!(
trimmed.to_ascii_lowercase().as_str(),
"<br>" | "<br/>" | "<br />"
) {
if self.in_cell {
self.current_cell.push_line_break();
}
} else {
self.push_text(html, style);
}
}
pub(super) fn end_cell(&mut self) {
self.current_row
.push(std::mem::take(&mut self.current_cell));
self.in_cell = false;
}
pub(super) fn end_row(&mut self) {
if !self.current_row.is_empty() {
self.rows.push(std::mem::take(&mut self.current_row));
}
}
}
+31
View File
@@ -27,6 +27,37 @@ fn plain_lines(text: &Text<'_>) -> Vec<String> {
.collect()
}
#[test]
fn markdown_table_renders_as_box_table() {
let text = render_markdown_text("| Tool | Use |\n| --- | --- |\n| rg | find |\n");
assert_eq!(
plain_lines(&text),
vec![
"┌──────┬──────┐",
"│ Tool │ Use │",
"├──────┼──────┤",
"│ rg │ find │",
"└──────┴──────┘",
]
);
}
#[test]
fn markdown_table_preserves_inline_code_pipe() {
let text = render_markdown_text("| Expr | Meaning |\n| --- | --- |\n| `a\\|b` | literal pipe |\n");
let rendered = plain_lines(&text).join("\n");
assert!(
rendered.contains("a|b"),
"inline code pipe should remain inside one cell: {rendered:?}"
);
assert!(
rendered.contains("literal pipe"),
"expected the second cell to survive table rendering: {rendered:?}"
);
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());
+5
View File
@@ -95,6 +95,11 @@ impl MarkdownStreamCollector {
Some(out)
}
/// Return source that has arrived but has not crossed a completed-line commit boundary.
pub(crate) fn uncommitted_source(&self) -> &str {
&self.buffer[self.committed_source_len..]
}
/// Finalize the stream and return any remaining raw source.
///
/// Ensures the returned source chunk is newline-terminated when non-empty so callers can
+153 -9
View File
@@ -23,6 +23,7 @@ use std::time::Duration;
use std::time::Instant;
use super::StreamState;
use super::source_partition::partition_source;
/// Shared source-retaining stream state for assistant and plan output.
///
@@ -36,6 +37,7 @@ struct StreamCore {
width: Option<usize>,
raw_source: String,
rendered_lines: Vec<Line<'static>>,
stable_source_end: usize,
enqueued_len: usize,
emitted_len: usize,
cwd: PathBuf,
@@ -48,6 +50,7 @@ impl StreamCore {
width,
raw_source: String::with_capacity(1024),
rendered_lines: Vec::with_capacity(64),
stable_source_end: 0,
enqueued_len: 0,
emitted_len: 0,
cwd: cwd.to_path_buf(),
@@ -64,8 +67,7 @@ impl StreamCore {
&& let Some(committed_source) = self.state.collector.commit_complete_source()
{
self.raw_source.push_str(&committed_source);
self.recompute_render();
return self.sync_queue_to_render();
return self.sync_stable_partition();
}
false
@@ -159,14 +161,43 @@ impl StreamCore {
self.state.clear();
self.raw_source.clear();
self.rendered_lines.clear();
self.stable_source_end = 0;
self.enqueued_len = 0;
self.emitted_len = 0;
}
fn active_tail_lines(&self) -> Vec<Line<'static>> {
let tail_start = self.stable_source_end.min(self.raw_source.len());
let mut source = String::new();
source.push_str(&self.raw_source[tail_start..]);
source.push_str(self.state.collector.uncommitted_source());
if source.trim().is_empty() {
return Vec::new();
}
if !source.ends_with('\n') {
source.push('\n');
}
let mut rendered = Vec::new();
append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered);
rendered
}
fn sync_stable_partition(&mut self) -> bool {
let partition = partition_source(&self.raw_source);
if partition.stable_end == self.stable_source_end {
return false;
}
self.stable_source_end = partition.stable_end;
self.recompute_render();
self.sync_queue_to_render()
}
fn recompute_render(&mut self) {
self.rendered_lines.clear();
let stable_source = &self.raw_source[..self.stable_source_end.min(self.raw_source.len())];
append_markdown(
&self.raw_source,
stable_source,
self.width,
Some(self.cwd.as_path()),
&mut self.rendered_lines,
@@ -289,6 +320,20 @@ impl StreamController {
self.core.set_width(width);
}
pub(crate) fn active_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
if self.core.queued_lines() > 0 {
return None;
}
let lines = self.core.active_tail_lines();
if lines.is_empty() {
return None;
}
Some(Box::new(history_cell::AgentMessageCell::new(
lines,
!self.header_emitted,
)))
}
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
if lines.is_empty() {
return None;
@@ -385,6 +430,41 @@ impl PlanStreamController {
self.core.set_width(width);
}
pub(crate) fn active_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
if self.core.queued_lines() > 0 {
return None;
}
let lines = self.core.active_tail_lines();
if lines.is_empty() {
return None;
}
let mut out_lines: Vec<Line<'static>> = Vec::with_capacity(lines.len() + 3);
let is_stream_continuation = self.header_emitted;
if !self.header_emitted {
out_lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into());
out_lines.push(Line::from(" "));
}
let mut plan_lines: Vec<Line<'static>> = Vec::with_capacity(lines.len() + 1);
if !self.top_padding_emitted {
plan_lines.push(Line::from(" "));
}
plan_lines.extend(lines);
let plan_style = proposed_plan_style();
let plan_lines = prefix_lines(plan_lines, " ".into(), " ".into())
.into_iter()
.map(|line| line.style(plan_style))
.collect::<Vec<_>>();
out_lines.extend(plan_lines);
Some(Box::new(history_cell::new_proposed_plan_stream(
out_lines,
is_stream_continuation,
)))
}
fn emit(
&mut self,
lines: Vec<Line<'static>>,
@@ -494,10 +574,75 @@ mod tests {
lines_to_plain_strings(&lines)
}
fn stream_active_tail_lines(ctrl: &StreamController) -> Vec<String> {
ctrl.active_tail_cell()
.expect("expected active tail cell")
.display_lines(u16::MAX)
.into_iter()
.map(|line| {
let plain = line
.spans
.iter()
.map(|span| span.content.clone())
.collect::<String>();
plain.chars().skip(2).collect::<String>()
})
.collect()
}
#[test]
fn controller_holds_table_as_active_tail_until_following_block() {
let mut ctrl = stream_controller(Some(80));
assert!(!ctrl.push("| Tool | Use |\n"));
assert!(!ctrl.push("| --- | --- |\n"));
assert!(!ctrl.push("| rg | find |\n"));
assert_eq!(
ctrl.queued_lines(),
0,
"a single table should not be committed while it is the mutable tail"
);
assert_eq!(
stream_active_tail_lines(&ctrl),
vec![
"┌──────┬──────┐",
"│ Tool │ Use │",
"├──────┼──────┤",
"│ rg │ find │",
"└──────┴──────┘",
]
);
assert!(ctrl.push("\nNext paragraph.\n"));
assert!(
ctrl.queued_lines() > 0,
"the table should become stable once a following block arrives"
);
}
#[test]
fn controller_active_tail_updates_without_committing_partial_table_rows() {
let mut ctrl = stream_controller(Some(80));
assert!(!ctrl.push("| Name | Value |\n| --- | --- |\n"));
assert_eq!(ctrl.queued_lines(), 0);
assert!(!ctrl.push("| alpha |"));
assert_eq!(
ctrl.queued_lines(),
0,
"partial table rows must stay out of committed history"
);
assert!(
stream_active_tail_lines(&ctrl).join("\n").contains("alpha"),
"active tail should preview the partial row"
);
}
#[test]
fn controller_set_width_rebuilds_queued_lines() {
let mut ctrl = stream_controller(Some(120));
let delta = "This is a long line that should wrap into multiple rows when resized.\n";
let delta = "This is a long line that should wrap into multiple rows when resized.\n\nNext block.\n";
assert!(ctrl.push(delta));
assert_eq!(ctrl.queued_lines(), 1);
@@ -519,8 +664,7 @@ mod tests {
#[test]
fn controller_set_width_no_duplicate_after_emit() {
let mut ctrl = stream_controller(Some(120));
let line =
"This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n";
let line = "This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n\nNext block.\n";
ctrl.push(line);
let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX);
assert!(cell.is_some(), "expected emitted cell");
@@ -538,7 +682,7 @@ mod tests {
#[test]
fn controller_tick_batch_zero_is_noop() {
let mut ctrl = stream_controller(Some(80));
assert!(ctrl.push("line one\n"));
assert!(ctrl.push("line one\n\nline two\n"));
assert_eq!(ctrl.queued_lines(), 1);
let (cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ 0);
@@ -554,7 +698,7 @@ mod tests {
#[test]
fn controller_finalize_returns_raw_source_for_consolidation() {
let mut ctrl = stream_controller(Some(80));
assert!(ctrl.push("hello\n"));
assert!(!ctrl.push("hello\n"));
let (_cell, source) = ctrl.finalize();
assert_eq!(source, Some("hello\n".to_string()));
}
@@ -562,7 +706,7 @@ mod tests {
#[test]
fn plan_controller_finalize_returns_raw_source_for_consolidation() {
let mut ctrl = plan_stream_controller(Some(80));
assert!(ctrl.push("- step\n"));
assert!(!ctrl.push("- step\n"));
let (_cell, source) = ctrl.finalize();
assert_eq!(source, Some("- step\n".to_string()));
}
+1
View File
@@ -20,6 +20,7 @@ use crate::markdown_stream::MarkdownStreamCollector;
pub(crate) mod chunking;
pub(crate) mod commit_tick;
pub(crate) mod controller;
pub(crate) mod source_partition;
struct QueuedLine {
line: Line<'static>,
@@ -0,0 +1,141 @@
use std::ops::Range;
use pulldown_cmark::Event;
use pulldown_cmark::Options;
use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SourcePartition {
pub(crate) stable_end: usize,
pub(crate) stable_blocks: Vec<Range<usize>>,
pub(crate) tail: Range<usize>,
}
pub(crate) fn partition_source(source: &str) -> SourcePartition {
let blocks = top_level_blocks(source);
let stable_end = if blocks.len() >= 2 {
blocks[blocks.len() - 2].end
} else {
0
};
let stable_blocks = blocks
.iter()
.filter(|range| range.end <= stable_end)
.cloned()
.collect();
SourcePartition {
stable_end,
stable_blocks,
tail: stable_end..source.len(),
}
}
fn top_level_blocks(source: &str) -> Vec<Range<usize>> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let mut blocks: Vec<Range<usize>> = Vec::new();
let mut depth = 0usize;
let mut block_start: Option<usize> = None;
for (event, range) in Parser::new_ext(source, options).into_offset_iter() {
match event {
Event::Start(tag) if is_block_start(&tag) => {
if depth == 0 {
block_start = Some(range.start);
}
depth += 1;
}
Event::End(tag) if is_block_end(tag) => {
if depth > 0 {
depth -= 1;
if depth == 0
&& let Some(start) = block_start.take()
{
blocks.push(start..range.end);
}
}
}
Event::Rule if depth == 0 => {
blocks.push(range.clone());
}
_ => {}
}
}
blocks
}
fn is_block_start(tag: &Tag<'_>) -> bool {
matches!(
tag,
Tag::Paragraph
| Tag::Heading { .. }
| Tag::BlockQuote
| Tag::CodeBlock(_)
| Tag::List(_)
| Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
| Tag::MetadataBlock(_)
)
}
fn is_block_end(tag: TagEnd) -> bool {
matches!(
tag,
TagEnd::Paragraph
| TagEnd::Heading(_)
| TagEnd::BlockQuote
| TagEnd::CodeBlock
| TagEnd::List(_)
| TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
| TagEnd::Table
| TagEnd::MetadataBlock(_)
)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn single_table_remains_tail() {
let source = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
let partition = partition_source(source);
assert_eq!(partition.stable_end, 0);
assert_eq!(&source[partition.tail], source);
}
#[test]
fn table_becomes_stable_after_later_block() {
let source = "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nDone.\n";
let partition = partition_source(source);
assert_eq!(
&source[..partition.stable_end],
"| A | B |\n| --- | --- |\n| 1 | 2 |\n"
);
assert_eq!(&source[partition.tail], "\nDone.\n");
assert_eq!(partition.stable_blocks.len(), 1);
}
#[test]
fn fenced_code_with_pipes_is_one_tail_block() {
let source = "```\n| A | B |\n| --- | --- |\n```\n";
let partition = partition_source(source);
assert_eq!(partition.stable_end, 0);
assert_eq!(&source[partition.tail], source);
}
}