Compare commits

...

2 Commits

Author SHA1 Message Date
Felipe Coury
bfc9223149 test(tui): cover streaming markdown tables 2026-04-29 12:08:07 -03:00
Felipe Coury
b81fff1b3c feat(tui): stream mutable markdown tails 2026-04-29 12:08:07 -03:00
10 changed files with 880 additions and 39 deletions

View File

@@ -2046,6 +2046,7 @@ impl ChatWidget {
fn flush_answer_stream_with_separator(&mut self) {
let had_stream_controller = self.stream_controller.is_some();
if let Some(mut controller) = self.stream_controller.take() {
self.clear_active_stream_tail();
let (cell, source) = controller.finalize();
if let Some(cell) = cell {
self.add_boxed_history(cell);
@@ -2631,11 +2632,10 @@ impl ChatWidget {
self.plan_delta_buffer.clear();
}
self.plan_delta_buffer.push_str(&delta);
// Before streaming plan content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
if self.plan_stream_controller.is_none() {
// Before streaming plan content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
self.plan_stream_controller = Some(PlanStreamController::new(
self.current_stream_width(/*reserved_cols*/ 4),
&self.config.cwd,
@@ -2647,6 +2647,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_active_stream_tail();
self.request_redraw();
}
@@ -2669,6 +2670,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_active_stream_tail();
controller.finalize()
} else {
(None, None)
@@ -2810,6 +2812,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_active_stream_tail();
let (cell, source) = controller.finalize();
if let Some(cell) = cell {
self.add_boxed_history(cell);
@@ -5015,6 +5018,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();
@@ -5059,11 +5063,10 @@ impl ChatWidget {
#[inline]
fn handle_streaming_delta(&mut self, delta: String) {
// Before streaming agent content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
if self.stream_controller.is_none() {
// Before streaming agent content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
// If the previous turn inserted non-stream history (exec output, patch status, MCP
// calls), render a separator before starting the next streamed assistant message.
if self.needs_final_message_separator && self.had_work_activity {
@@ -5086,6 +5089,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_active_stream_tail();
self.request_redraw();
}
@@ -6146,12 +6150,49 @@ impl ChatWidget {
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
// Only break exec grouping if the cell renders visible lines.
self.flush_active_cell();
if !self.active_cell_is_stream_tail() {
self.flush_active_cell();
}
self.needs_final_message_separator = true;
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(cell));
}
fn active_cell_is_stream_tail(&self) -> bool {
self.active_cell.as_ref().is_some_and(|cell| {
cell.as_any().is::<history_cell::StreamingAgentTailCell>()
|| cell.as_any().is::<history_cell::ProposedPlanStreamCell>()
})
}
fn sync_active_stream_tail(&mut self) {
let tail_cell = self
.stream_controller
.as_ref()
.and_then(StreamController::current_tail_cell)
.or_else(|| {
self.plan_stream_controller
.as_ref()
.and_then(PlanStreamController::current_tail_cell)
});
match tail_cell {
Some(cell) => {
self.bottom_pane.hide_status_indicator();
self.active_cell = Some(cell);
self.bump_active_cell_revision();
}
None => self.clear_active_stream_tail(),
}
}
fn clear_active_stream_tail(&mut self) {
if self.active_cell_is_stream_tail() {
self.active_cell = None;
self.bump_active_cell_revision();
}
}
fn queue_user_message(&mut self, user_message: UserMessage) {
self.queue_user_message_with_options(user_message, QueuedInputAction::Plain);
}
@@ -11633,6 +11674,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();
}

View File

@@ -0,0 +1,8 @@
---
source: tui/src/chatwidget/tests/status_and_layout.rs
expression: active_blob(&chat)
---
• ┌──────┬───────┐
│ Name │ Value │
├──────┼───────┤
└──────┴───────┘

View File

@@ -928,6 +928,40 @@ async fn streaming_final_answer_keeps_task_running_state() {
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn streaming_markdown_tail_updates_in_place_until_next_block_stabilizes_it() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.on_agent_message_delta("First paragraph\n".to_string());
assert_eq!(drain_insert_history(&mut rx).len(), 0);
assert_eq!(active_blob(&chat), "• First paragraph\n");
chat.on_agent_message_delta("\nSecond paragraph\n".to_string());
chat.on_commit_tick();
let inserted = drain_insert_history(&mut rx);
assert_eq!(inserted.len(), 1);
assert_eq!(lines_to_single_string(&inserted[0]), "• First paragraph\n");
assert_eq!(active_blob(&chat), " \n Second paragraph\n");
}
#[tokio::test]
async fn streaming_table_tail_renders_from_complete_lines_only() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.on_agent_message_delta("| Name | Value |\n".to_string());
assert_eq!(drain_insert_history(&mut rx).len(), 0);
assert_eq!(active_blob(&chat), "• | Name | Value |\n");
chat.on_agent_message_delta("| --- | --- |\n".to_string());
assert_eq!(drain_insert_history(&mut rx).len(), 0);
assert_chatwidget_snapshot!("streaming_table_tail_active", active_blob(&chat));
chat.on_agent_message_delta("| A | 1 |".to_string());
assert_chatwidget_snapshot!("streaming_table_tail_active", active_blob(&chat));
}
#[tokio::test]
async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;

View File

@@ -539,6 +539,40 @@ impl HistoryCell for AgentMarkdownCell {
}
}
/// Transient preview of the mutable final markdown block in an agent stream.
#[derive(Debug)]
pub(crate) struct StreamingAgentTailCell {
lines: Vec<Line<'static>>,
is_first_line: bool,
}
impl StreamingAgentTailCell {
pub(crate) fn new(lines: Vec<Line<'static>>, is_first_line: bool) -> Self {
Self {
lines,
is_first_line,
}
}
}
impl HistoryCell for StreamingAgentTailCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
prefix_lines(
self.lines.clone(),
if self.is_first_line {
"".dim()
} else {
" ".into()
},
" ".into(),
)
}
fn is_stream_continuation(&self) -> bool {
!self.is_first_line
}
}
#[derive(Debug)]
pub(crate) struct PlainHistoryCell {
lines: Vec<Line<'static>>,

View File

@@ -7,10 +7,12 @@
use crate::render::highlight::highlight_code_to_lines;
use crate::render::line_utils::line_to_static;
use crate::render::line_utils::push_owned_lines;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use codex_utils_string::normalize_markdown_hash_location_suffix;
use dirs::home_dir;
use pulldown_cmark::Alignment;
use pulldown_cmark::CodeBlockKind;
use pulldown_cmark::CowStr;
use pulldown_cmark::Event;
@@ -27,6 +29,7 @@ use regex_lite::Regex;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use unicode_width::UnicodeWidthStr;
use url::Url;
struct MarkdownStyles {
@@ -86,6 +89,62 @@ impl IndentContext {
}
}
#[derive(Clone, Debug, Default)]
struct TableCell {
lines: Vec<Line<'static>>,
}
impl TableCell {
fn push_span(&mut self, span: Span<'static>) {
if self.lines.is_empty() {
self.lines.push(Line::default());
}
if let Some(line) = self.lines.last_mut() {
line.push_span(span);
}
}
fn hard_break(&mut self) {
self.lines.push(Line::default());
}
fn plain_text(&self) -> String {
let mut out = String::new();
for (idx, line) in self.lines.iter().enumerate() {
if idx > 0 {
out.push(' ');
}
for span in &line.spans {
out.push_str(span.content.as_ref());
}
}
out
}
}
#[derive(Debug)]
struct TableState {
alignments: Vec<Alignment>,
header: Option<Vec<TableCell>>,
rows: Vec<Vec<TableCell>>,
current_row: Option<Vec<TableCell>>,
current_cell: Option<TableCell>,
in_head: bool,
}
impl TableState {
fn new(alignments: Vec<Alignment>) -> Self {
Self {
alignments,
header: None,
rows: Vec::new(),
current_row: None,
current_cell: None,
in_head: false,
}
}
}
pub fn render_markdown_text(input: &str) -> Text<'static> {
render_markdown_text_with_width(input, /*width*/ None)
}
@@ -106,14 +165,19 @@ pub(crate) fn render_markdown_text_with_width_and_cwd(
width: Option<usize>,
cwd: Option<&Path>,
) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let parser = markdown_parser(input);
let mut w = Writer::new(parser, width, cwd);
w.run();
w.text
}
pub(crate) fn markdown_parser(input: &str) -> Parser<'_> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
Parser::new_ext(input, options)
}
#[derive(Clone, Debug)]
struct LinkState {
destination: String,
@@ -172,6 +236,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 +269,7 @@ where
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
table: None,
}
}
@@ -277,12 +343,12 @@ 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(alignments) => self.start_table(alignments),
Tag::TableHead => self.start_table_head(),
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(_) => {}
}
@@ -306,12 +372,12 @@ where
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
TagEnd::Link => self.pop_link(),
TagEnd::Table => self.end_table(),
TagEnd::TableHead => self.end_table_head(),
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(_) => {}
}
@@ -391,6 +457,18 @@ where
self.code_block_buffer.push_str(&text);
return;
}
if self.table_current_cell_mut().is_some() {
for (idx, line) in text.lines().enumerate() {
if idx > 0
&& let Some(cell) = self.table_current_cell_mut()
{
cell.hard_break();
}
let style = self.inline_styles.last().copied().unwrap_or_default();
self.push_span(Span::styled(line.to_string(), style));
}
return;
}
if self.in_code_block && !self.needs_newline {
let has_content = self
@@ -464,6 +542,10 @@ where
return;
}
self.line_ends_with_local_link_target = false;
if let Some(cell) = self.table_current_cell_mut() {
cell.hard_break();
return;
}
self.push_line(Line::default());
}
@@ -477,6 +559,10 @@ where
return;
}
self.line_ends_with_local_link_target = false;
if let Some(cell) = self.table_current_cell_mut() {
cell.hard_break();
return;
}
self.push_line(Line::default());
}
@@ -593,6 +679,287 @@ where
self.indent_stack.pop();
}
fn start_table(&mut self, alignments: Vec<Alignment>) {
self.flush_current_line();
if self.needs_newline && !self.text.lines.is_empty() {
self.push_blank_line();
}
self.table = Some(TableState::new(alignments));
self.needs_newline = false;
}
fn end_table(&mut self) {
let Some(table) = self.table.take() else {
return;
};
for line in self.render_table_lines(table) {
self.push_unwrapped_line(line);
}
self.needs_newline = true;
}
fn start_table_head(&mut self) {
if let Some(table) = &mut self.table {
table.in_head = true;
table.current_row.get_or_insert_with(Vec::new);
}
}
fn end_table_head(&mut self) {
if let Some(table) = &mut self.table {
if let Some(row) = table.current_row.take() {
table.header = Some(row);
}
table.in_head = false;
}
}
fn start_table_row(&mut self) {
if let Some(table) = &mut self.table {
table.current_row = Some(Vec::new());
}
}
fn end_table_row(&mut self) {
let Some(table) = &mut self.table else {
return;
};
let row = table.current_row.take().unwrap_or_default();
if table.in_head {
table.header = Some(row);
} else {
table.rows.push(row);
}
}
fn start_table_cell(&mut self) {
if let Some(table) = &mut self.table {
table.current_cell = Some(TableCell::default());
}
}
fn end_table_cell(&mut self) {
let Some(table) = &mut self.table else {
return;
};
let cell = table.current_cell.take().unwrap_or_default();
table.current_row.get_or_insert_with(Vec::new).push(cell);
}
fn table_current_cell_mut(&mut self) -> Option<&mut TableCell> {
self.table.as_mut()?.current_cell.as_mut()
}
fn render_table_lines(&self, mut table: TableState) -> Vec<Line<'static>> {
let column_count = table
.alignments
.len()
.max(table.header.as_ref().map_or(0, Vec::len))
.max(table.rows.iter().map(Vec::len).max().unwrap_or(0));
if column_count == 0 {
return Vec::new();
}
table.alignments.resize(column_count, Alignment::None);
let mut header = table
.header
.unwrap_or_else(|| vec![TableCell::default(); column_count]);
header.resize(column_count, TableCell::default());
for row in &mut table.rows {
row.resize(column_count, TableCell::default());
}
let available_width = self.wrap_width.unwrap_or(usize::MAX);
let border_overhead = column_count.saturating_mul(3).saturating_add(1);
let inner_width = available_width.saturating_sub(border_overhead);
let min_widths = self.table_min_widths(&header, &table.rows);
let ideal_widths = self.table_ideal_widths(&header, &table.rows);
let widths = self.fit_table_widths(ideal_widths, min_widths, inner_width);
let mut out = Vec::new();
out.push(Self::render_table_border('┌', '┬', '┐', &widths));
let header_lines = self.render_table_row(&header, &widths, &table.alignments);
out.extend(header_lines);
out.push(Self::render_table_border('├', '┼', '┤', &widths));
let mut max_row_height = 0usize;
for row in &table.rows {
let row_lines = self.render_table_row(row, &widths, &table.alignments);
max_row_height = max_row_height.max(row_lines.len());
out.extend(row_lines);
}
out.push(Self::render_table_border('└', '┴', '┘', &widths));
const MAX_ROW_LINES: usize = 8;
if max_row_height > MAX_ROW_LINES {
self.render_vertical_table(&header, &table.rows, available_width)
} else {
out
}
}
fn table_min_widths(&self, header: &[TableCell], rows: &[Vec<TableCell>]) -> Vec<usize> {
(0..header.len())
.map(|column| {
std::iter::once(&header[column])
.chain(rows.iter().map(|row| &row[column]))
.map(|cell| Self::longest_token_width(&cell.plain_text()))
.max()
.unwrap_or(1)
.max(1)
})
.collect()
}
fn table_ideal_widths(&self, header: &[TableCell], rows: &[Vec<TableCell>]) -> Vec<usize> {
(0..header.len())
.map(|column| {
std::iter::once(&header[column])
.chain(rows.iter().map(|row| &row[column]))
.map(Self::cell_display_width)
.max()
.unwrap_or(1)
.max(1)
})
.collect()
}
fn fit_table_widths(
&self,
mut widths: Vec<usize>,
min_widths: Vec<usize>,
available_width: usize,
) -> Vec<usize> {
if min_widths.iter().sum::<usize>() > available_width {
return min_widths;
}
while widths.iter().sum::<usize>() > available_width {
let Some((idx, _)) = widths
.iter()
.enumerate()
.filter(|(idx, width)| **width > min_widths[*idx])
.max_by_key(|(idx, width)| width.saturating_sub(min_widths[*idx]))
else {
break;
};
widths[idx] -= 1;
}
widths
}
fn render_table_border(left: char, sep: char, right: char, widths: &[usize]) -> Line<'static> {
let mut spans = Vec::with_capacity(widths.len() * 2 + 1);
spans.push(Span::from(left.to_string()));
for (idx, width) in widths.iter().enumerate() {
spans.push(Span::from("".repeat(*width + 2)));
spans.push(Span::from(
if idx + 1 == widths.len() { right } else { sep }.to_string(),
));
}
Line::from(spans)
}
fn render_table_row(
&self,
row: &[TableCell],
widths: &[usize],
alignments: &[Alignment],
) -> Vec<Line<'static>> {
let wrapped = row
.iter()
.zip(widths)
.map(|(cell, width)| self.wrap_table_cell(cell, *width))
.collect::<Vec<_>>();
let height = wrapped.iter().map(Vec::len).max().unwrap_or(1);
let mut out = Vec::with_capacity(height);
for row_line in 0..height {
let mut spans = vec![Span::from("")];
for (column, width) in widths.iter().enumerate() {
spans.push(Span::from(" "));
let line = wrapped[column].get(row_line).cloned().unwrap_or_default();
let line_width = Self::line_display_width(&line);
let remaining = width.saturating_sub(line_width);
let (left_pad, right_pad) = match alignments[column] {
Alignment::Left | Alignment::None => (0, remaining),
Alignment::Center => (remaining / 2, remaining - remaining / 2),
Alignment::Right => (remaining, 0),
};
if left_pad > 0 {
spans.push(Span::from(" ".repeat(left_pad)));
}
spans.extend(line.spans);
if right_pad > 0 {
spans.push(Span::from(" ".repeat(right_pad)));
}
spans.push(Span::from(" "));
spans.push(Span::from(""));
}
out.push(Line::from(spans));
}
out
}
fn wrap_table_cell(&self, cell: &TableCell, width: usize) -> Vec<Line<'static>> {
if cell.lines.is_empty() {
return vec![Line::default()];
}
let mut out = Vec::new();
for line in &cell.lines {
let wrapped = adaptive_wrap_line(line, RtOptions::new(width.max(1)));
if wrapped.is_empty() {
out.push(Line::default());
} else {
push_owned_lines(&wrapped, &mut out);
}
}
out
}
fn render_vertical_table(
&self,
header: &[TableCell],
rows: &[Vec<TableCell>],
width: usize,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
for (row_idx, row) in rows.iter().enumerate() {
if row_idx > 0 {
out.push(Line::default());
}
for (header_cell, value_cell) in header.iter().zip(row) {
let label = header_cell.plain_text();
let value = value_cell.plain_text();
let text = if label.is_empty() {
value
} else {
format!("{label}: {value}")
};
let line = Line::from(text);
let wrapped = adaptive_wrap_line(
&line,
RtOptions::new(width.max(1)).subsequent_indent(" ".into()),
);
push_owned_lines(&wrapped, &mut out);
}
}
out
}
fn line_display_width(line: &Line<'_>) -> usize {
line.spans.iter().map(|span| span.content.width()).sum()
}
fn cell_display_width(cell: &TableCell) -> usize {
cell.lines
.iter()
.map(Self::line_display_width)
.max()
.unwrap_or(0)
}
fn longest_token_width(text: &str) -> usize {
text.split_whitespace().map(str::width).max().unwrap_or(0)
}
fn push_inline_style(&mut self, style: Style) {
let current = self.inline_styles.last().copied().unwrap_or_default();
let merged = current.patch(style);
@@ -697,7 +1064,29 @@ where
self.pending_marker_line = false;
}
fn push_unwrapped_line(&mut self, mut line: Line<'static>) {
self.flush_current_line();
let blockquote_active = self
.indent_stack
.iter()
.any(|ctx| ctx.prefix.iter().any(|s| s.content.contains('>')));
let style = if blockquote_active {
self.styles.blockquote
} else {
line.style
};
let mut spans = self.prefix_spans(/*pending_marker_line*/ false);
spans.append(&mut line.spans);
self.text.lines.push(Line::from_iter(spans).style(style));
self.pending_marker_line = false;
self.line_ends_with_local_link_target = false;
}
fn push_span(&mut self, span: Span<'static>) {
if let Some(cell) = self.table_current_cell_mut() {
cell.push_span(span);
return;
}
if let Some(line) = self.current_line_content.as_mut() {
line.push_span(span);
} else {

View File

@@ -56,6 +56,59 @@ fn paragraph_multiple() {
);
}
#[test]
fn table_renders_as_box() {
let text = render_markdown_text("| Name | Value |\n| --- | --- |\n| A | 1 |\n");
assert_eq!(
plain_lines(&text),
vec![
"┌──────┬───────┐",
"│ Name │ Value │",
"├──────┼───────┤",
"│ A │ 1 │",
"└──────┴───────┘",
],
);
}
#[test]
fn table_respects_column_alignment() {
let text = render_markdown_text("| Left | Center | Right |\n| :--- | :---: | ---: |\n| a | b | c |\n");
assert_eq!(
plain_lines(&text),
vec![
"┌──────┬────────┬───────┐",
"│ Left │ Center │ Right │",
"├──────┼────────┼───────┤",
"│ a │ b │ c │",
"└──────┴────────┴───────┘",
],
);
}
#[test]
fn narrow_table_stays_boxed_even_when_it_overflows() {
let text = render_markdown_text_with_width_and_cwd(
"| Name | Description |\n| --- | --- |\n| Alpha | a value that wraps |\n",
Some(20),
/*cwd*/ None,
);
assert_eq!(
plain_lines(&text),
vec![
"┌───────┬─────────────┐",
"│ Name │ Description │",
"├───────┼─────────────┤",
"│ Alpha │ a value │",
"│ │ that wraps │",
"└───────┴─────────────┘",
],
);
}
#[test]
fn headings() {
let md = "# Heading 1\n## Heading 2\n### Heading 3\n#### Heading 4\n##### Heading 5\n###### Heading 6\n";
@@ -1203,6 +1256,7 @@ Table below (alignment test):
| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |
Inline HTML: <sup>sup</sup> and <sub>sub</sub>.
HTML block:
<div style="border:1px solid #ccc;padding:2px">inline block</div>

View File

@@ -28,9 +28,13 @@ Image: alt text
———
Table below (alignment test):
| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |
┌──────┬────────┬───────┐
│ Left │ Center │ Right │
├──────┼────────┼───────┤
│ a │ b │ c │
└──────┴────────┴───────┘
Inline HTML: <sup>sup</sup> and <sub>sub</sub>.
HTML block:
<div style="border:1px solid #ccc;padding:2px">inline block</div>

View File

@@ -23,19 +23,22 @@ use std::time::Duration;
use std::time::Instant;
use super::StreamState;
use super::partition::MarkdownStreamPartition;
use super::partition::partition_completed_source;
/// Shared source-retaining stream state for assistant and plan output.
///
/// `raw_source` is the markdown source that has crossed a newline boundary and can be rendered
/// deterministically. `rendered_lines` is the current-width render of that source. `enqueued_len`
/// tracks how much of that render has been offered to the commit queue, while `emitted_len` tracks
/// how much has actually reached history cells. Keeping those counters separate lets width changes
/// rebuild pending output without duplicating lines that are already visible.
/// deterministically. `rendered_lines` is the current-width render of that source. Only the
/// rendered stable prefix is queued; the final top-level block remains mutable and is shown by the
/// owning widget as an active tail cell.
struct StreamCore {
state: StreamState,
width: Option<usize>,
raw_source: String,
rendered_lines: Vec<Line<'static>>,
rendered_stable_lines: Vec<Line<'static>>,
partition: MarkdownStreamPartition,
enqueued_len: usize,
emitted_len: usize,
cwd: PathBuf,
@@ -48,6 +51,8 @@ impl StreamCore {
width,
raw_source: String::with_capacity(1024),
rendered_lines: Vec::with_capacity(64),
rendered_stable_lines: Vec::with_capacity(64),
partition: MarkdownStreamPartition::default(),
enqueued_len: 0,
emitted_len: 0,
cwd: cwd.to_path_buf(),
@@ -65,7 +70,7 @@ impl StreamCore {
{
self.raw_source.push_str(&committed_source);
self.recompute_render();
return self.sync_queue_to_render();
return self.sync_queue_to_stable_render();
}
false
@@ -118,6 +123,18 @@ impl StreamCore {
self.state.is_idle()
}
fn current_tail_lines(&self) -> Vec<Line<'static>> {
let stable_len = self
.rendered_stable_lines
.len()
.min(self.rendered_lines.len());
self.rendered_lines[stable_len..].to_vec()
}
fn tail_starts_stream(&self) -> bool {
self.emitted_len == 0
}
fn set_width(&mut self, width: Option<usize>) {
if self.width == width {
return;
@@ -159,11 +176,14 @@ impl StreamCore {
self.state.clear();
self.raw_source.clear();
self.rendered_lines.clear();
self.rendered_stable_lines.clear();
self.partition = MarkdownStreamPartition::default();
self.enqueued_len = 0;
self.emitted_len = 0;
}
fn recompute_render(&mut self) {
self.partition = partition_completed_source(&self.raw_source);
self.rendered_lines.clear();
append_markdown(
&self.raw_source,
@@ -171,6 +191,13 @@ impl StreamCore {
Some(self.cwd.as_path()),
&mut self.rendered_lines,
);
self.rendered_stable_lines.clear();
append_markdown(
&self.raw_source[..self.partition.stable_end],
self.width,
Some(self.cwd.as_path()),
&mut self.rendered_stable_lines,
);
}
/// Append newly rendered lines to the live queue without replaying already queued rows.
@@ -178,8 +205,8 @@ impl StreamCore {
/// Width changes can make the rendered line count smaller than the previous queue boundary; in
/// that case the only safe option is rebuilding the queue from `emitted_len`, because slicing
/// from the stale `enqueued_len` would skip pending source.
fn sync_queue_to_render(&mut self) -> bool {
let target_len = self.rendered_lines.len().max(self.emitted_len);
fn sync_queue_to_stable_render(&mut self) -> bool {
let target_len = self.rendered_stable_lines.len().max(self.emitted_len);
if target_len < self.enqueued_len {
self.rebuild_queue_from_render();
return self.state.queued_len() > 0;
@@ -190,7 +217,7 @@ impl StreamCore {
}
self.state
.enqueue(self.rendered_lines[self.enqueued_len..target_len].to_vec());
.enqueue(self.rendered_stable_lines[self.enqueued_len..target_len].to_vec());
self.enqueued_len = target_len;
true
}
@@ -201,10 +228,10 @@ impl StreamCore {
/// `emitted_len`, because those rows have already been inserted into terminal history.
fn rebuild_queue_from_render(&mut self) {
self.state.clear_queue();
let target_len = self.rendered_lines.len().max(self.emitted_len);
let target_len = self.rendered_stable_lines.len().max(self.emitted_len);
if self.emitted_len < target_len {
self.state
.enqueue(self.rendered_lines[self.emitted_len..target_len].to_vec());
.enqueue(self.rendered_stable_lines[self.emitted_len..target_len].to_vec());
}
self.enqueued_len = target_len;
}
@@ -289,6 +316,16 @@ impl StreamController {
self.core.set_width(width);
}
pub(crate) fn current_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
let lines = self.core.current_tail_lines();
(!lines.is_empty()).then(|| {
Box::new(history_cell::StreamingAgentTailCell::new(
lines,
self.core.tail_starts_stream(),
)) as Box<dyn HistoryCell>
})
}
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
if lines.is_empty() {
return None;
@@ -385,6 +422,14 @@ impl PlanStreamController {
self.core.set_width(width);
}
pub(crate) fn current_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
let lines = self.core.current_tail_lines();
if lines.is_empty() {
return None;
}
Some(self.render_tail_preview(lines))
}
fn emit(
&mut self,
lines: Vec<Line<'static>>,
@@ -424,6 +469,32 @@ impl PlanStreamController {
is_stream_continuation,
)))
}
fn render_tail_preview(&self, lines: Vec<Line<'static>>) -> Box<dyn HistoryCell> {
let mut out_lines = Vec::with_capacity(4);
if !self.header_emitted {
out_lines.push(vec!["".dim(), "Proposed Plan".bold()].into());
out_lines.push(Line::from(" "));
}
let mut plan_lines = Vec::with_capacity(4);
if !self.top_padding_emitted {
plan_lines.push(Line::from(" "));
}
plan_lines.extend(lines);
let plan_style = proposed_plan_style();
out_lines.extend(
prefix_lines(plan_lines, " ".into(), " ".into())
.into_iter()
.map(|line| line.style(plan_style)),
);
Box::new(history_cell::new_proposed_plan_stream(
out_lines,
self.header_emitted,
))
}
}
#[cfg(test)]
@@ -497,7 +568,8 @@ mod tests {
#[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 +591,11 @@ 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 = concat!(
"This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n",
"\n",
"next block\n",
);
ctrl.push(line);
let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX);
assert!(cell.is_some(), "expected emitted cell");
@@ -538,7 +613,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 +629,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,11 +637,65 @@ 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()));
}
#[test]
fn one_block_stays_in_active_tail_until_finalization() {
let mut ctrl = stream_controller(Some(80));
assert!(!ctrl.push("hello\n"));
assert_eq!(ctrl.queued_lines(), 0);
let tail = ctrl.current_tail_cell().expect("expected active tail");
assert_eq!(
lines_to_plain_strings(&tail.transcript_lines(u16::MAX)),
vec!["• hello".to_string()],
);
}
#[test]
fn later_block_stabilizes_previous_block() {
let mut ctrl = stream_controller(Some(80));
assert!(ctrl.push("hello\n\nworld\n"));
let stable = ctrl
.on_commit_tick_batch(usize::MAX)
.0
.expect("expected stable prefix cell");
assert_eq!(
lines_to_plain_strings(&stable.transcript_lines(u16::MAX)),
vec!["• hello".to_string()],
);
let tail = ctrl.current_tail_cell().expect("expected active tail");
assert_eq!(
lines_to_plain_strings(&tail.transcript_lines(u16::MAX)),
vec![" ".to_string(), " world".to_string()],
);
}
#[test]
fn plan_stream_keeps_one_block_in_active_tail() {
let mut ctrl = plan_stream_controller(Some(80));
assert!(!ctrl.push("- one\n"));
assert_eq!(ctrl.queued_lines(), 0);
let tail = ctrl.current_tail_cell().expect("expected active tail");
assert_eq!(
lines_to_plain_strings(&tail.transcript_lines(u16::MAX)),
vec![
"• Proposed Plan".to_string(),
" ".to_string(),
" ".to_string(),
" - one".to_string(),
],
);
}
#[test]
fn simple_lines_stream_in_order() {
let actual = collect_streamed_lines(&["hello\n", "world\n"], Some(80));

View File

@@ -20,6 +20,7 @@ use crate::markdown_stream::MarkdownStreamCollector;
pub(crate) mod chunking;
pub(crate) mod commit_tick;
pub(crate) mod controller;
mod partition;
struct QueuedLine {
line: Line<'static>,

View File

@@ -0,0 +1,146 @@
//! Block-level partitioning for streaming markdown previews.
//!
//! Completed source is split into two regions: every top-level block before
//! the last block is stable, while the final block remains mutable until a
//! later block appears or the stream finalizes.
use std::ops::Range;
use pulldown_cmark::Event;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
use crate::markdown_render::markdown_parser;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct MarkdownStreamPartition {
pub(super) stable_end: usize,
}
pub(super) fn partition_completed_source(source: &str) -> MarkdownStreamPartition {
let blocks = top_level_block_ranges(source);
let stable_end = blocks
.iter()
.rev()
.nth(1)
.map_or(0, |previous_block| previous_block.end);
MarkdownStreamPartition { stable_end }
}
fn top_level_block_ranges(source: &str) -> Vec<Range<usize>> {
let mut blocks = Vec::new();
let mut block_start = None;
let mut depth = 0usize;
for (event, range) in markdown_parser(source).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 {
continue;
}
depth -= 1;
if depth == 0
&& let Some(start) = block_start.take()
{
blocks.push(start..range.end);
}
}
Event::Rule if depth == 0 => blocks.push(range),
_ => {}
}
}
blocks
}
fn is_block_start(tag: &Tag<'_>) -> bool {
matches!(
tag,
Tag::Paragraph
| Tag::Heading { .. }
| Tag::BlockQuote
| Tag::CodeBlock(_)
| Tag::HtmlBlock
| Tag::List(_)
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
| Tag::MetadataBlock(_)
)
}
fn is_block_end(tag: TagEnd) -> bool {
matches!(
tag,
TagEnd::Paragraph
| TagEnd::Heading(_)
| TagEnd::BlockQuote
| TagEnd::CodeBlock
| TagEnd::HtmlBlock
| TagEnd::List(_)
| TagEnd::FootnoteDefinition
| TagEnd::Table
| TagEnd::MetadataBlock(_)
)
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::partition_completed_source;
#[test]
fn one_block_has_no_stable_prefix() {
assert_eq!(partition_completed_source("hello\n"), Default::default());
}
#[test]
fn second_top_level_block_stabilizes_the_first() {
assert_eq!(
partition_completed_source("first\n\nsecond\n").stable_end,
"first\n".len(),
);
}
#[test]
fn nested_blocks_do_not_split_the_tail() {
assert_eq!(
partition_completed_source("> quoted\n>\n> still quoted\n").stable_end,
0,
);
}
#[test]
fn rule_counts_as_a_top_level_block() {
assert_eq!(
partition_completed_source("before\n\n---\n\nafter\n").stable_end,
"before\n\n---\n".len(),
);
}
#[test]
fn table_remains_mutable_until_a_later_block_arrives() {
assert_eq!(
partition_completed_source("| A | B |\n| --- | --- |\n| 1 | 2 |\n").stable_end,
0,
);
assert_eq!(
partition_completed_source("| A | B |\n| --- | --- |\n| 1 | 2 |\n\nnext\n").stable_end,
"| A | B |\n| --- | --- |\n| 1 | 2 |\n".len(),
);
}
#[test]
fn fenced_table_text_stays_inside_code_block() {
assert_eq!(
partition_completed_source("```\n| A | B |\n| --- | --- |\n```\n").stable_end,
0,
);
}
}