mirror of
https://github.com/openai/codex.git
synced 2026-05-06 20:36:33 +00:00
Compare commits
2 Commits
pr20420
...
fcoury/mar
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bfc9223149 | ||
|
|
b81fff1b3c |
@@ -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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: active_blob(&chat)
|
||||
---
|
||||
• ┌──────┬───────┐
|
||||
│ Name │ Value │
|
||||
├──────┼───────┤
|
||||
└──────┴───────┘
|
||||
@@ -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;
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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>,
|
||||
|
||||
146
codex-rs/tui/src/streaming/partition.rs
Normal file
146
codex-rs/tui/src/streaming/partition.rs
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user