diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index e7a58d448a7f..4116fb2bef11 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -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::() + || cell.as_any().is::() + }) + } + + 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(); } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__streaming_table_tail_active.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__streaming_table_tail_active.snap new file mode 100644 index 000000000000..bad62d1247cf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__streaming_table_tail_active.snap @@ -0,0 +1,8 @@ +--- +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: active_blob(&chat) +--- +• ┌──────┬───────┐ + │ Name │ Value │ + ├──────┼───────┤ + └──────┴───────┘ diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index c71c57dfef8f..fe61d843563b 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -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; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 9d009a93fb35..7fab7316ccfa 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -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>, + is_first_line: bool, +} + +impl StreamingAgentTailCell { + pub(crate) fn new(lines: Vec>, is_first_line: bool) -> Self { + Self { + lines, + is_first_line, + } + } +} + +impl HistoryCell for StreamingAgentTailCell { + fn display_lines(&self, _width: u16) -> Vec> { + 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>, diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index 3dfef1d9d8ce..83075e1d6ea8 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -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>, +} + +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, + header: Option>, + rows: Vec>, + current_row: Option>, + current_cell: Option, + in_head: bool, +} + +impl TableState { + fn new(alignments: Vec) -> 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, 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>, current_line_style: Style, current_line_in_code_block: bool, + table: Option, } 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) { + 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> { + 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]) -> Vec { + (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]) -> Vec { + (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, + min_widths: Vec, + available_width: usize, + ) -> Vec { + if min_widths.iter().sum::() > available_width { + return min_widths; + } + while widths.iter().sum::() > 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> { + let wrapped = row + .iter() + .zip(widths) + .map(|(cell, width)| self.wrap_table_cell(cell, *width)) + .collect::>(); + 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> { + 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], + width: usize, + ) -> Vec> { + 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 { diff --git a/codex-rs/tui/src/markdown_render_tests.rs b/codex-rs/tui/src/markdown_render_tests.rs index cb15c5ff70ca..7719518beefa 100644 --- a/codex-rs/tui/src/markdown_render_tests.rs +++ b/codex-rs/tui/src/markdown_render_tests.rs @@ -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 and sub. HTML block:
inline block
diff --git a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap index cc752dd66d41..a838e0955f3e 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__markdown_render__markdown_render_tests__markdown_render_complex_snapshot.snap @@ -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 and sub. HTML block:
inline block
diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 2def4ae8bba8..46632173d367 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -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, raw_source: String, rendered_lines: Vec>, + rendered_stable_lines: Vec>, + 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> { + 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) { 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> { + 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 + }) + } + fn emit(&mut self, lines: Vec>) -> Option> { 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> { + 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>, @@ -424,6 +469,32 @@ impl PlanStreamController { is_stream_continuation, ))) } + + fn render_tail_preview(&self, lines: Vec>) -> Box { + 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)); diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index ddbac2e4c547..65a6b61f14f5 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -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>, diff --git a/codex-rs/tui/src/streaming/partition.rs b/codex-rs/tui/src/streaming/partition.rs new file mode 100644 index 000000000000..2138ecaaa268 --- /dev/null +++ b/codex-rs/tui/src/streaming/partition.rs @@ -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> { + 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, + ); + } +}