mirror of
https://github.com/openai/codex.git
synced 2026-10-20 08:19:50 +00:00
Make transcript list spacing uniform after streaming (#46899)
## Why Adding blank lines only after multiline items gives lists uneven spacing. Completed lists in redrawable transcript views can use consistent spacing based on their rendered width. ## What changed - Keep assistant and plan lists compact while streaming in the fullscreen transcript, without holding back completed list rows. - On completion or interruption, separate all sibling items if any item spans multiple rows; keep single-line lists compact and recompute spacing on resize. - Preserve the reader's displayed content when consolidating streamed cells, and include spacing policy in the Markdown render cache key. - Retain existing list spacing for native terminal scrollback. ## Testing Add regression and snapshot coverage for streaming completion and interruption, width-dependent reflow, reader position, nested and quoted lists, item paragraphs, and preservation of links and source metadata. GitOrigin-RevId: b2230aaf76ae798ebb6bf15563f8b8dcb6f3d19d
This commit is contained in:
@@ -5,7 +5,7 @@
|
||||
//! tail in the bottom pane. Once the answer finishes, the app replaces that
|
||||
//! trailing run with a single source-backed `AgentMarkdownCell`. This makes the
|
||||
//! transcript the canonical owner of the raw markdown source used for future
|
||||
//! resize re-renders. The retained view remaps its reading position before that
|
||||
//! resize re-renders. The retained view preserves its displayed revision before that
|
||||
//! replacement, then repaints without replaying terminal scrollback.
|
||||
|
||||
use std::path::PathBuf;
|
||||
@@ -67,13 +67,24 @@ impl App {
|
||||
);
|
||||
self.native_history
|
||||
.consolidate(&self.transcript_cells[start..end], &consolidated);
|
||||
self.transcript_view
|
||||
.replace_range(&self.transcript_cells, start..end, &consolidated);
|
||||
if tui.is_owned_screen() {
|
||||
self.transcript_view.replace_group(
|
||||
&self.transcript_cells,
|
||||
start..end,
|
||||
&consolidated,
|
||||
);
|
||||
} else {
|
||||
self.transcript_view.replace_range(
|
||||
&self.transcript_cells,
|
||||
start..end,
|
||||
&consolidated,
|
||||
);
|
||||
}
|
||||
self.transcript_cells
|
||||
.splice(start..end, std::iter::once(consolidated.clone()));
|
||||
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.consolidate_cells(start..end, consolidated.clone());
|
||||
t.regroup_cells(start..end, consolidated.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
|
||||
@@ -798,12 +798,16 @@ impl App {
|
||||
|
||||
if start < end {
|
||||
self.native_history.consolidate(&self.transcript_cells[start..end], &consolidated);
|
||||
self.transcript_view.replace_range(&self.transcript_cells, start..end, &consolidated);
|
||||
if tui.is_owned_screen() {
|
||||
self.transcript_view.replace_group(&self.transcript_cells, start..end, &consolidated);
|
||||
} else {
|
||||
self.transcript_view.replace_range(&self.transcript_cells, start..end, &consolidated);
|
||||
}
|
||||
self.transcript_cells
|
||||
.splice(start..end, std::iter::once(consolidated.clone()));
|
||||
|
||||
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
|
||||
t.consolidate_cells(start..end, consolidated.clone());
|
||||
t.regroup_cells(start..end, consolidated.clone());
|
||||
tui.frame_requester().schedule_frame();
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::*;
|
||||
use crate::app::tests::make_test_app_with_channels;
|
||||
use crate::app_command::AppCommand;
|
||||
use crate::app_event::ConsolidationScrollbackReflow;
|
||||
use crate::chatwidget::tests::helpers::normalize_snapshot_paths;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::UserHistoryCell;
|
||||
@@ -72,6 +73,52 @@ fn buffer_text(buffer: &Buffer) -> String {
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_spacing_completion_preserves_the_scrolled_reader() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
app.transcript_cells = vec![Arc::new(history_cell::AgentMessageCell::new(
|
||||
vec![
|
||||
"- First item wraps onto".into(),
|
||||
" a second row".into(),
|
||||
"- b".into(),
|
||||
"- c".into(),
|
||||
],
|
||||
/*is_first_line*/ true,
|
||||
))];
|
||||
let mut tui = crate::tui::test_support::make_test_tui()?;
|
||||
tui.set_owned_screen(/*owned*/ true)?;
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 26, /*height*/ 1,
|
||||
);
|
||||
let mut before = Buffer::empty(area);
|
||||
app.transcript_view
|
||||
.jump_to_entry(&app.transcript_cells, /*index*/ 0);
|
||||
app.transcript_view
|
||||
.render(area, &mut before, &app.transcript_cells);
|
||||
app.transcript_view
|
||||
.scroll(&app.transcript_cells, /*rows*/ 3);
|
||||
app.transcript_view
|
||||
.render(area, &mut before, &app.transcript_cells);
|
||||
assert!(buffer_text(&before).contains("- c"));
|
||||
app.handle_consolidate_agent_message(
|
||||
&mut tui,
|
||||
"- First item wraps onto a second row\n- b\n- c".into(),
|
||||
app.config.cwd.to_path_buf(),
|
||||
/*inline_visualization_context*/ None,
|
||||
ConsolidationScrollbackReflow::Required,
|
||||
/*deferred_history_cell*/ None,
|
||||
)?;
|
||||
let mut after = Buffer::empty(area);
|
||||
app.transcript_view
|
||||
.render(area, &mut after, &app.transcript_cells);
|
||||
assert_eq!(after, before);
|
||||
app.transcript_view.jump_to_latest();
|
||||
app.transcript_view
|
||||
.render(area, &mut after, &app.transcript_cells);
|
||||
assert_eq!(after, before);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn older_page_loading_uses_the_status_row_without_moving_content_or_cursor() -> Result<()> {
|
||||
let mut app = crate::app::test_support::make_test_app().await;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
//! cells, commit ticks, and interrupt deferral.
|
||||
|
||||
use super::*;
|
||||
use crate::markdown_render::ListSpacing;
|
||||
|
||||
fn latest_summary_line(text: &str) -> Option<String> {
|
||||
text.lines().rev().find_map(|line| {
|
||||
@@ -214,11 +215,20 @@ impl ChatWidget {
|
||||
// Before starting a plan stream, 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,
|
||||
self.history_render_mode(),
|
||||
));
|
||||
self.plan_stream_controller = Some(
|
||||
PlanStreamController::new(
|
||||
self.current_stream_width(/*reserved_cols*/ 4),
|
||||
&self.config.cwd,
|
||||
self.history_render_mode(),
|
||||
)
|
||||
.with_list_spacing(
|
||||
if self.local_settings.transcript_mode.is_owned() {
|
||||
ListSpacing::Compact
|
||||
} else {
|
||||
ListSpacing::AfterMultiline
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
let changed = self
|
||||
.plan_stream_controller
|
||||
@@ -572,12 +582,21 @@ impl ChatWidget {
|
||||
thread_id,
|
||||
)
|
||||
});
|
||||
self.stream_controller = Some(StreamController::new_with_inline_visualizations(
|
||||
self.current_stream_width(/*reserved_cols*/ 2),
|
||||
&self.config.cwd,
|
||||
self.history_render_mode(),
|
||||
inline_visualization_context,
|
||||
));
|
||||
self.stream_controller = Some(
|
||||
StreamController::new_with_inline_visualizations(
|
||||
self.current_stream_width(/*reserved_cols*/ 2),
|
||||
&self.config.cwd,
|
||||
self.history_render_mode(),
|
||||
inline_visualization_context,
|
||||
)
|
||||
.with_list_spacing(
|
||||
if self.local_settings.transcript_mode.is_owned() {
|
||||
ListSpacing::Compact
|
||||
} else {
|
||||
ListSpacing::AfterMultiline
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
let changed = self
|
||||
.stream_controller
|
||||
|
||||
@@ -314,5 +314,8 @@ pub(super) use helpers::*;
|
||||
#[path = "tests/questions_tests.rs"]
|
||||
mod questions_tests;
|
||||
|
||||
#[path = "tests/list_spacing_tests.rs"]
|
||||
mod list_spacing_tests;
|
||||
|
||||
#[path = "tests/question_notifications_tests.rs"]
|
||||
mod question_notifications_tests;
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
//! Exercise compact streaming and source-backed completion without list-specific holdback.
|
||||
|
||||
use super::*;
|
||||
use crate::local_settings::LocalSettings;
|
||||
use codex_config::types::AltScreenMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_spacing_streams_compact_then_reflows_on_completion_or_interruption() {
|
||||
let source = "1. One\n2. This item has enough words to wrap\n3. Three\n4. Four";
|
||||
let mut stages = Vec::new();
|
||||
for (enabled, alternate_screen) in [
|
||||
(true, AltScreenMode::Always),
|
||||
(false, AltScreenMode::Always),
|
||||
(true, AltScreenMode::Never),
|
||||
] {
|
||||
for plan in [false, true] {
|
||||
for interrupted in [false, true] {
|
||||
let (mut chat, mut rx, _ops) =
|
||||
make_chatwidget_manual(/*model_override*/ None).await;
|
||||
chat.config.tui_fullscreen_transcript = enabled;
|
||||
chat.config.tui_alternate_screen = alternate_screen;
|
||||
chat.local_settings = LocalSettings::from(&chat.config);
|
||||
let owned = chat.local_settings.transcript_mode.is_owned();
|
||||
let width = if plan { 28 } else { 26 };
|
||||
chat.note_rendered_width(width);
|
||||
if plan {
|
||||
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
|
||||
chat.set_collaboration_mask(CollaborationModeMask {
|
||||
name: "Plan".into(),
|
||||
mode: Some(ModeKind::Plan),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
developer_instructions: None,
|
||||
});
|
||||
}
|
||||
drain_insert_history(&mut rx);
|
||||
let mut live = Vec::new();
|
||||
for chunk in source.split_inclusive('\n') {
|
||||
let (cell, tail) = if plan {
|
||||
chat.on_plan_delta(chunk.into());
|
||||
let controller = chat.plan_stream_controller.as_mut().unwrap();
|
||||
(
|
||||
controller.on_commit_tick_batch(usize::MAX).0,
|
||||
controller.current_tail_display_lines(),
|
||||
)
|
||||
} else {
|
||||
chat.on_agent_message_delta(chunk.into());
|
||||
let controller = chat.stream_controller.as_mut().unwrap();
|
||||
(
|
||||
controller.on_commit_tick_batch(usize::MAX).0,
|
||||
controller.current_tail_lines(),
|
||||
)
|
||||
};
|
||||
live.extend(
|
||||
drain_insert_history_with(&mut rx, |cell| cell.display_lines(width))
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
);
|
||||
if let Some(cell) = cell {
|
||||
live.extend(cell.display_lines(width));
|
||||
}
|
||||
if chunk.ends_with('\n') {
|
||||
assert!(tail.is_empty(), "completed list rows must not be held back");
|
||||
}
|
||||
}
|
||||
if interrupted {
|
||||
chat.flush_answer_and_plan_streams();
|
||||
} else if plan {
|
||||
chat.on_plan_item_completed(source.into());
|
||||
} else {
|
||||
chat.finalize_completed_assistant_message(Some(source));
|
||||
}
|
||||
let mut completed = None;
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
match event {
|
||||
AppEvent::InsertHistoryCell(cell) => live.extend(cell.display_lines(width)),
|
||||
AppEvent::ConsolidateAgentMessage {
|
||||
source: final_source,
|
||||
cwd,
|
||||
deferred_history_cell,
|
||||
..
|
||||
} => {
|
||||
if let Some(cell) = deferred_history_cell {
|
||||
live.extend(cell.display_lines(width));
|
||||
}
|
||||
assert_eq!(final_source.trim_end(), source);
|
||||
completed = Some(Box::new(history_cell::AgentMarkdownCell::new(
|
||||
final_source,
|
||||
&cwd,
|
||||
))
|
||||
as Box<dyn HistoryCell>);
|
||||
}
|
||||
AppEvent::ConsolidateProposedPlan(final_source) => {
|
||||
assert_eq!(final_source.trim_end(), source);
|
||||
completed = Some(Box::new(history_cell::new_proposed_plan(
|
||||
final_source,
|
||||
&chat.config.cwd,
|
||||
))
|
||||
as Box<dyn HistoryCell>);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let completed = completed.expect("termination must retain source for reflow");
|
||||
let text = |lines: Vec<Line<'static>>| {
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| line.to_string().trim_end().to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
};
|
||||
let render = |width| {
|
||||
if owned {
|
||||
completed.retained_hyperlink_lines(width, /*detailed*/ false)
|
||||
} else {
|
||||
completed.display_hyperlink_lines(width)
|
||||
}
|
||||
};
|
||||
let narrow = render(width);
|
||||
let native = completed.display_hyperlink_lines(width);
|
||||
if owned {
|
||||
assert_ne!(native, narrow);
|
||||
}
|
||||
let wide = render(/*width*/ 100);
|
||||
assert_eq!(
|
||||
render(width),
|
||||
narrow,
|
||||
"width and spacing must both key the render cache"
|
||||
);
|
||||
let live = text(live);
|
||||
assert_eq!(live.contains("wrap\n\n"), !owned);
|
||||
let stage = format!(
|
||||
"owned={owned}, plan={plan}\nStreaming:\n{live}\nCompleted:\n{}\nWide:\n{}",
|
||||
text(crate::terminal_hyperlinks::visible_lines(narrow)),
|
||||
text(crate::terminal_hyperlinks::visible_lines(wide))
|
||||
);
|
||||
if enabled && alternate_screen == AltScreenMode::Always {
|
||||
if interrupted {
|
||||
assert_eq!(stages.last(), Some(&stage));
|
||||
} else {
|
||||
stages.push(stage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!(stages.join("\n\n---\n\n"));
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/list_spacing_tests.rs
|
||||
expression: "stages.join(\"\\n\\n---\\n\\n\")"
|
||||
---
|
||||
owned=true, plan=false
|
||||
Streaming:
|
||||
• 1. One
|
||||
2. This item has enough
|
||||
words to wrap
|
||||
3. Three
|
||||
4. Four
|
||||
Completed:
|
||||
• 1. One
|
||||
|
||||
2. This item has enough
|
||||
words to wrap
|
||||
|
||||
3. Three
|
||||
|
||||
4. Four
|
||||
Wide:
|
||||
• 1. One
|
||||
2. This item has enough words to wrap
|
||||
3. Three
|
||||
4. Four
|
||||
|
||||
---
|
||||
|
||||
owned=true, plan=true
|
||||
Streaming:
|
||||
• Proposed Plan
|
||||
|
||||
|
||||
1. One
|
||||
2. This item has enough
|
||||
words to wrap
|
||||
3. Three
|
||||
4. Four
|
||||
|
||||
Completed:
|
||||
• Proposed Plan
|
||||
|
||||
|
||||
1. One
|
||||
|
||||
2. This item has enough
|
||||
words to wrap
|
||||
|
||||
3. Three
|
||||
|
||||
4. Four
|
||||
|
||||
Wide:
|
||||
• Proposed Plan
|
||||
|
||||
|
||||
1. One
|
||||
2. This item has enough words to wrap
|
||||
3. Three
|
||||
4. Four
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Single-width render cache shared by finalized source-backed markdown history cells.
|
||||
|
||||
use crate::markdown_render::ListSpacing;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::PoisonError;
|
||||
@@ -12,6 +13,7 @@ pub(super) struct MarkdownRenderCache {
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(super) struct MarkdownRenderCacheKey {
|
||||
pub(super) width: u16,
|
||||
pub(super) list_spacing: ListSpacing,
|
||||
pub(super) syntax_theme_revision: u64,
|
||||
pub(super) terminal_fg: Option<(u8, u8, u8)>,
|
||||
pub(super) terminal_bg: Option<(u8, u8, u8)>,
|
||||
@@ -26,10 +28,12 @@ impl MarkdownRenderCache {
|
||||
pub(super) fn render(
|
||||
&self,
|
||||
width: u16,
|
||||
list_spacing: ListSpacing,
|
||||
render: impl FnOnce() -> Vec<HyperlinkLine>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let key = MarkdownRenderCacheKey {
|
||||
width,
|
||||
list_spacing,
|
||||
syntax_theme_revision: crate::render::highlight::syntax_theme_revision(),
|
||||
terminal_fg: crate::terminal_palette::default_fg(),
|
||||
terminal_bg: crate::terminal_palette::default_bg(),
|
||||
|
||||
@@ -572,12 +572,12 @@ fn normalize_whitespace_only_hyperlink_lines(mut lines: Vec<HyperlinkLine>) -> V
|
||||
lines
|
||||
}
|
||||
|
||||
impl HistoryCell for AgentMarkdownCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
impl AgentMarkdownCell {
|
||||
fn render_lines(
|
||||
&self,
|
||||
width: u16,
|
||||
list_spacing: crate::markdown_render::ListSpacing,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let render = || {
|
||||
let Some(wrap_width) =
|
||||
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
|
||||
@@ -591,11 +591,12 @@ impl HistoryCell for AgentMarkdownCell {
|
||||
|
||||
// Re-render markdown from source at the current width. Reserve 2 columns for the "• " /
|
||||
// " " prefix prepended below.
|
||||
let lines = crate::markdown::render_markdown_agent_with_links_cwd_and_visualizations(
|
||||
let lines = crate::markdown::render_markdown_agent_with_list_spacing(
|
||||
&self.markdown_source,
|
||||
Some(wrap_width),
|
||||
Some(self.cwd.as_path()),
|
||||
self.inline_visualization_context.as_ref(),
|
||||
list_spacing,
|
||||
);
|
||||
let lines = if self.spoken_artifacts {
|
||||
let mut lines = lines;
|
||||
@@ -612,11 +613,25 @@ impl HistoryCell for AgentMarkdownCell {
|
||||
};
|
||||
|
||||
if let Some(rendered_lines) = &self.rendered_lines {
|
||||
rendered_lines.render(width, render)
|
||||
rendered_lines.render(width, list_spacing, render)
|
||||
} else {
|
||||
render()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for AgentMarkdownCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.render_lines(width, crate::markdown_render::ListSpacing::AfterMultiline)
|
||||
}
|
||||
|
||||
fn retained_hyperlink_lines(&self, width: u16, _detailed: bool) -> Vec<HyperlinkLine> {
|
||||
self.render_lines(width, crate::markdown_render::ListSpacing::Uniform)
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
|
||||
@@ -235,6 +235,15 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync + Any {
|
||||
self.display_hyperlink_lines(width)
|
||||
}
|
||||
|
||||
/// Rich presentation in a viewport that can redraw previously visible rows.
|
||||
fn retained_hyperlink_lines(&self, width: u16, detailed: bool) -> Vec<HyperlinkLine> {
|
||||
if detailed {
|
||||
self.transcript_hyperlink_lines(width)
|
||||
} else {
|
||||
self.compact_hyperlink_lines(width)
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable, namespaced member identities used to retain disclosure across grouping and replay.
|
||||
/// Empty identities indicate ordinary content without a local disclosure control.
|
||||
fn activity_ids(&self) -> Vec<String> {
|
||||
|
||||
@@ -113,13 +113,13 @@ pub(crate) struct ProposedPlanStreamCell {
|
||||
is_stream_continuation: bool,
|
||||
}
|
||||
|
||||
impl HistoryCell for ProposedPlanCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.rendered_lines.render(width, || {
|
||||
impl ProposedPlanCell {
|
||||
fn render_lines(
|
||||
&self,
|
||||
width: u16,
|
||||
list_spacing: crate::markdown_render::ListSpacing,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
self.rendered_lines.render(width, list_spacing, || {
|
||||
let mut lines = vec![
|
||||
HyperlinkLine::new(vec!["• ".dim(), "Proposed Plan".bold()].into()),
|
||||
HyperlinkLine::new(Line::from(" ")),
|
||||
@@ -128,10 +128,12 @@ impl HistoryCell for ProposedPlanCell {
|
||||
let mut plan_lines = vec![HyperlinkLine::new(Line::from(" "))];
|
||||
let plan_style = proposed_plan_style();
|
||||
let wrap_width = width.saturating_sub(4).max(1) as usize;
|
||||
let mut body = crate::markdown::render_markdown_agent_with_links_and_cwd(
|
||||
let mut body = crate::markdown::render_markdown_agent_with_list_spacing(
|
||||
&self.plan_markdown,
|
||||
Some(wrap_width),
|
||||
Some(self.cwd.as_path()),
|
||||
/*inline_visualization_context*/ None,
|
||||
list_spacing,
|
||||
);
|
||||
if body.is_empty() {
|
||||
body.push(HyperlinkLine::new(Line::from("(empty)".dim().italic())));
|
||||
@@ -143,6 +145,20 @@ impl HistoryCell for ProposedPlanCell {
|
||||
lines
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for ProposedPlanCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.render_lines(width, crate::markdown_render::ListSpacing::AfterMultiline)
|
||||
}
|
||||
|
||||
fn retained_hyperlink_lines(&self, width: u16, _detailed: bool) -> Vec<HyperlinkLine> {
|
||||
self.render_lines(width, crate::markdown_render::ListSpacing::Uniform)
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.display_hyperlink_lines(width)
|
||||
|
||||
@@ -31,6 +31,7 @@ use std::sync::Arc;
|
||||
|
||||
use crate::inline_visualization::InlineVisualizationContext;
|
||||
use crate::inline_visualization::rewrite_inline_visualizations;
|
||||
use crate::markdown_render::ListSpacing;
|
||||
use crate::table_detect;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
|
||||
@@ -73,24 +74,27 @@ pub(crate) fn append_markdown_agent(
|
||||
crate::render::line_utils::push_owned_lines(&rendered.lines, lines);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn render_markdown_agent_with_links_and_cwd(
|
||||
markdown_source: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
render_markdown_agent_with_links_cwd_and_visualizations(
|
||||
render_markdown_agent_with_list_spacing(
|
||||
markdown_source,
|
||||
width,
|
||||
cwd,
|
||||
/*inline_visualization_context*/ None,
|
||||
ListSpacing::AfterMultiline,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn render_markdown_agent_with_links_cwd_and_visualizations(
|
||||
pub(crate) fn render_markdown_agent_with_list_spacing(
|
||||
markdown_source: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
inline_visualization_context: Option<&InlineVisualizationContext>,
|
||||
list_spacing: ListSpacing,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
let rewritten = rewrite_inline_visualizations(markdown_source, inline_visualization_context);
|
||||
let normalized = unwrap_markdown_fences(&rewritten.markdown);
|
||||
@@ -103,6 +107,7 @@ pub(crate) fn render_markdown_agent_with_links_cwd_and_visualizations(
|
||||
width,
|
||||
cwd,
|
||||
&is_hidden_link_destination,
|
||||
list_spacing,
|
||||
)
|
||||
.lines;
|
||||
for hyperlink in lines.iter_mut().flat_map(|line| &mut line.hyperlinks) {
|
||||
@@ -122,6 +127,7 @@ pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
|
||||
markdown_source: &str,
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
list_spacing: ListSpacing,
|
||||
) -> crate::markdown_render::StreamingMarkdownRender {
|
||||
let normalized = unwrap_markdown_fences(markdown_source);
|
||||
let mut rendered = crate::markdown_render::render_streaming_markdown_lines_with_width_and_cwd(
|
||||
@@ -129,6 +135,7 @@ pub(crate) fn render_streaming_markdown_agent_with_links_and_cwd(
|
||||
width,
|
||||
cwd,
|
||||
&crate::markdown_render::hide_web_link_destination,
|
||||
list_spacing,
|
||||
);
|
||||
if normalized != markdown_source {
|
||||
// Fence unwrapping removes opening/closing lines. A normalized tail that is still a raw
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
//! `markdown.rs`.
|
||||
//!
|
||||
//! Local file-link parsing and display policy live in [`local_links`].
|
||||
//! List spacing stays a renderer policy: compact while streaming in an owned viewport, uniform
|
||||
//! after source-backed consolidation, and historical spacing for native scrollback.
|
||||
//!
|
||||
//! ## Table rendering pipeline
|
||||
//!
|
||||
@@ -72,6 +74,7 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
mod file_citations;
|
||||
mod list_spacing;
|
||||
mod local_links;
|
||||
mod math;
|
||||
mod mermaid;
|
||||
@@ -80,11 +83,16 @@ mod table_key_value;
|
||||
mod web_links;
|
||||
|
||||
use file_citations::FileCitations;
|
||||
pub(crate) use list_spacing::ListSpacing;
|
||||
use list_spacing::UniformList;
|
||||
use local_links::is_local_path_like_link;
|
||||
use local_links::render_local_link_target;
|
||||
use local_links::should_render_local_link_label;
|
||||
pub(crate) use streaming::StreamingMarkdownRender;
|
||||
pub(crate) use streaming::render_streaming_markdown_lines_with_width_and_cwd;
|
||||
#[cfg(test)]
|
||||
#[path = "markdown_render/list_spacing_tests.rs"]
|
||||
mod list_spacing_tests;
|
||||
pub(crate) use web_links::hide_web_link_destination;
|
||||
use web_links::style_bare_web_urls;
|
||||
|
||||
@@ -403,6 +411,8 @@ where
|
||||
list_indices: Vec<Option<u64>>,
|
||||
list_needs_blank_before_next_item: Vec<bool>,
|
||||
list_item_start_line_counts: Vec<usize>,
|
||||
list_spacing: ListSpacing,
|
||||
uniform_lists: Vec<UniformList>,
|
||||
link: Option<LinkState>,
|
||||
needs_newline: bool,
|
||||
pending_marker_line: bool,
|
||||
@@ -445,6 +455,8 @@ where
|
||||
list_indices: Vec::new(),
|
||||
list_needs_blank_before_next_item: Vec::new(),
|
||||
list_item_start_line_counts: Vec::new(),
|
||||
list_spacing: ListSpacing::default(),
|
||||
uniform_lists: Vec::new(),
|
||||
link: None,
|
||||
needs_newline: false,
|
||||
pending_marker_line: false,
|
||||
@@ -564,6 +576,12 @@ where
|
||||
TagEnd::Item => {
|
||||
self.flush_current_line();
|
||||
let start_line_count = self.list_item_start_line_counts.pop().unwrap_or_default();
|
||||
if let Some(list) = self.uniform_lists.last_mut() {
|
||||
let rows = &self.text[start_line_count..];
|
||||
list.multiline |= rows.len() > 1
|
||||
|| (rows.len() == 1
|
||||
&& self.wrap_width.is_some_and(|width| rows[0].width() > width));
|
||||
}
|
||||
if self.text.len().saturating_sub(start_line_count) > 1
|
||||
&& let Some(needs_blank) = self.list_needs_blank_before_next_item.last_mut()
|
||||
{
|
||||
@@ -820,24 +838,45 @@ where
|
||||
}
|
||||
self.list_indices.push(index);
|
||||
self.list_needs_blank_before_next_item.push(false);
|
||||
if self.list_spacing == ListSpacing::Uniform {
|
||||
self.uniform_lists.push(UniformList::default());
|
||||
}
|
||||
}
|
||||
|
||||
fn end_list(&mut self) {
|
||||
self.flush_current_line();
|
||||
if let Some(list) = self.uniform_lists.pop() {
|
||||
list.finish(&mut self.text);
|
||||
}
|
||||
self.list_indices.pop();
|
||||
self.list_needs_blank_before_next_item.pop();
|
||||
self.needs_newline = true;
|
||||
}
|
||||
|
||||
fn start_item(&mut self) {
|
||||
if self
|
||||
let after_multiline = self
|
||||
.list_needs_blank_before_next_item
|
||||
.last_mut()
|
||||
.map(std::mem::take)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
self.push_blank_line();
|
||||
}
|
||||
.unwrap_or(/*default*/ false);
|
||||
self.flush_current_line();
|
||||
let separate = match self.list_spacing {
|
||||
ListSpacing::AfterMultiline => after_multiline,
|
||||
ListSpacing::Compact => false,
|
||||
ListSpacing::Uniform => self.uniform_lists.last().is_some_and(|list| list.has_item),
|
||||
};
|
||||
if separate {
|
||||
let index = self.text.len();
|
||||
self.push_blank_line();
|
||||
if self.text.len() > index
|
||||
&& let Some(list) = self.uniform_lists.last_mut()
|
||||
{
|
||||
list.separators.push(index);
|
||||
}
|
||||
}
|
||||
if let Some(list) = self.uniform_lists.last_mut() {
|
||||
list.has_item = true;
|
||||
}
|
||||
self.list_item_start_line_counts.push(self.text.len());
|
||||
self.pending_marker_line = true;
|
||||
let depth = self.list_indices.len();
|
||||
@@ -880,7 +919,14 @@ where
|
||||
|
||||
fn start_codeblock(&mut self, lang: Option<String>, indent: Option<Span<'static>>) {
|
||||
self.flush_current_line();
|
||||
if !self.text.is_empty() {
|
||||
let first_item_block = self.pending_marker_line
|
||||
&& self
|
||||
.indent_stack
|
||||
.last()
|
||||
.is_some_and(|context| context.is_list);
|
||||
if !self.text.is_empty()
|
||||
&& (self.list_spacing == ListSpacing::AfterMultiline || !first_item_block)
|
||||
{
|
||||
self.push_blank_line();
|
||||
}
|
||||
self.in_code_block = true;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
//! List spacing is a layout policy, not a streaming boundary. Completed redrawable lists use
|
||||
//! uniform sibling separators; streaming lists stay compact until source-backed consolidation.
|
||||
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
|
||||
pub(crate) enum ListSpacing {
|
||||
/// Preserve the historical spacing for terminal-owned scrollback.
|
||||
#[default]
|
||||
AfterMultiline,
|
||||
/// Do not add separators between siblings while an owned transcript streams.
|
||||
Compact,
|
||||
/// Separate every sibling if any item occupies multiple rendered rows.
|
||||
Uniform,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct UniformList {
|
||||
pub(super) has_item: bool,
|
||||
pub(super) multiline: bool,
|
||||
pub(super) separators: Vec<usize>,
|
||||
}
|
||||
|
||||
impl UniformList {
|
||||
pub(super) fn finish(self, lines: &mut Vec<HyperlinkLine>) {
|
||||
if self.multiline {
|
||||
return;
|
||||
}
|
||||
let mut separators = self.separators.into_iter().peekable();
|
||||
let Some(&start) = separators.peek() else {
|
||||
return;
|
||||
};
|
||||
// Only remove this list's provisional separators, preserving source and link metadata.
|
||||
let tail = lines.split_off(start);
|
||||
lines.extend(tail.into_iter().enumerate().filter_map(|(offset, line)| {
|
||||
if separators.peek() == Some(&(start + offset)) {
|
||||
separators.next();
|
||||
None
|
||||
} else {
|
||||
Some(line)
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
//! Width-aware list policies preserve nested layout, paragraphs, and semantic link metadata.
|
||||
|
||||
use super::ListSpacing;
|
||||
use crate::markdown::render_markdown_agent_with_list_spacing;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn list_spacing_is_uniform_per_list_and_preserves_item_paragraphs() {
|
||||
let sources = [
|
||||
"1. One\n2. This item has enough words to wrap\n3. Three\n4. Four",
|
||||
"- Parent\n - Short\n - Small\n- Other\n\n> - Quote\n> - This quoted item has enough words to wrap\n> - End",
|
||||
"- First paragraph\n\n Second paragraph\n\n- Next\n\n- Last",
|
||||
"- One\n- ```text\n code\n ```\n- Three\n\n ```text\n after paragraph\n ```",
|
||||
"- https://example.com/a-very-long-path-with-many-segments\n- Short\n- End",
|
||||
];
|
||||
let mut stages = Vec::new();
|
||||
for source in sources {
|
||||
for spacing in [ListSpacing::Compact, ListSpacing::Uniform] {
|
||||
let lines = render_markdown_agent_with_list_spacing(
|
||||
source,
|
||||
Some(24),
|
||||
/*cwd*/ None,
|
||||
/*inline_visualization_context*/ None,
|
||||
spacing,
|
||||
);
|
||||
stages.push(format!(
|
||||
"{spacing:?}:\n{}",
|
||||
lines
|
||||
.iter()
|
||||
.map(|line| line.line.to_string().trim_end().to_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!(stages.join("\n\n---\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_spacing_preserves_links_and_source_when_removing_separators() {
|
||||
let source = "- [One](https://example.com/one)\n- [Two](https://example.com/two)\n- Three";
|
||||
let render = |spacing| {
|
||||
render_markdown_agent_with_list_spacing(
|
||||
source,
|
||||
Some(100),
|
||||
/*cwd*/ None,
|
||||
/*inline_visualization_context*/ None,
|
||||
spacing,
|
||||
)
|
||||
};
|
||||
let compact = render(ListSpacing::Compact);
|
||||
let uniform = render(ListSpacing::Uniform);
|
||||
assert_eq!(&compact, &uniform);
|
||||
assert_eq!(
|
||||
compact.iter().map(|line| &line.source).collect::<Vec<_>>(),
|
||||
uniform.iter().map(|line| &line.source).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
---
|
||||
source: tui/src/markdown_render/list_spacing_tests.rs
|
||||
expression: "stages.join(\"\\n\\n---\\n\\n\")"
|
||||
---
|
||||
Compact:
|
||||
1. One
|
||||
2. This item has enough
|
||||
words to wrap
|
||||
3. Three
|
||||
4. Four
|
||||
|
||||
---
|
||||
|
||||
Uniform:
|
||||
1. One
|
||||
|
||||
2. This item has enough
|
||||
words to wrap
|
||||
|
||||
3. Three
|
||||
|
||||
4. Four
|
||||
|
||||
---
|
||||
|
||||
Compact:
|
||||
- Parent
|
||||
- Short
|
||||
- Small
|
||||
- Other
|
||||
|
||||
> - Quote
|
||||
> - This quoted item has
|
||||
> enough words to wrap
|
||||
> - End
|
||||
|
||||
---
|
||||
|
||||
Uniform:
|
||||
- Parent
|
||||
- Short
|
||||
- Small
|
||||
|
||||
- Other
|
||||
|
||||
> - Quote
|
||||
>
|
||||
> - This quoted item has
|
||||
> enough words to wrap
|
||||
>
|
||||
> - End
|
||||
|
||||
---
|
||||
|
||||
Compact:
|
||||
- First paragraph
|
||||
|
||||
Second paragraph
|
||||
- Next
|
||||
- Last
|
||||
|
||||
---
|
||||
|
||||
Uniform:
|
||||
- First paragraph
|
||||
|
||||
Second paragraph
|
||||
|
||||
- Next
|
||||
|
||||
- Last
|
||||
|
||||
---
|
||||
|
||||
Compact:
|
||||
- One
|
||||
-
|
||||
code
|
||||
- Three
|
||||
|
||||
after paragraph
|
||||
|
||||
---
|
||||
|
||||
Uniform:
|
||||
- One
|
||||
|
||||
-
|
||||
code
|
||||
|
||||
- Three
|
||||
|
||||
after paragraph
|
||||
|
||||
---
|
||||
|
||||
Compact:
|
||||
- https://example.com/a-very-long-path-with-many-segments
|
||||
- Short
|
||||
- End
|
||||
|
||||
---
|
||||
|
||||
Uniform:
|
||||
- https://example.com/a-very-long-path-with-many-segments
|
||||
|
||||
- Short
|
||||
|
||||
- End
|
||||
@@ -7,6 +7,7 @@ use super::DecodedTextMerge;
|
||||
use super::Event;
|
||||
use super::FileCitations;
|
||||
use super::HyperlinkLine;
|
||||
use super::ListSpacing;
|
||||
use super::Options;
|
||||
use super::Parser;
|
||||
use super::Tag;
|
||||
@@ -40,6 +41,7 @@ pub(crate) fn render_streaming_markdown_lines_with_width_and_cwd(
|
||||
width: Option<usize>,
|
||||
cwd: Option<&Path>,
|
||||
is_hidden_link_destination: &dyn Fn(&str) -> bool,
|
||||
list_spacing: ListSpacing,
|
||||
) -> StreamingMarkdownRender {
|
||||
let mut options = Options::empty();
|
||||
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||
@@ -57,6 +59,7 @@ pub(crate) fn render_streaming_markdown_lines_with_width_and_cwd(
|
||||
mermaid_start: None,
|
||||
};
|
||||
let mut writer = Writer::new(input, parser, width, cwd, is_hidden_link_destination);
|
||||
writer.list_spacing = list_spacing;
|
||||
writer.run();
|
||||
StreamingMarkdownRender {
|
||||
lines: writer.text,
|
||||
|
||||
@@ -238,6 +238,7 @@ fn streaming_and_full_render_agree_with_label_only_links() {
|
||||
Some(width),
|
||||
/*cwd*/ None,
|
||||
&|destination| display.hide_destination(destination),
|
||||
crate::markdown_render::ListSpacing::AfterMultiline,
|
||||
);
|
||||
assert_eq!(streamed.lines, render(prefix, width, display));
|
||||
}
|
||||
|
||||
@@ -3346,11 +3346,17 @@ fn render_transcript_content_lines(
|
||||
conversation_user_style(),
|
||||
)],
|
||||
TranscriptPreviewSpeaker::Assistant => {
|
||||
let mut lines = render_assistant(&line.text, /*width*/ None, cwd, &|_| false)
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|line| line.line)
|
||||
.collect::<Vec<_>>();
|
||||
let mut lines = render_assistant(
|
||||
&line.text,
|
||||
/*width*/ None,
|
||||
cwd,
|
||||
&|_| false,
|
||||
crate::markdown_render::ListSpacing::AfterMultiline,
|
||||
)
|
||||
.lines
|
||||
.into_iter()
|
||||
.map(|line| line.line)
|
||||
.collect::<Vec<_>>();
|
||||
for line in &mut lines {
|
||||
*line = conversation_content_line(line.clone(), conversation_assistant_style());
|
||||
}
|
||||
|
||||
@@ -44,7 +44,8 @@ use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::HistoryRenderMode;
|
||||
use crate::history_cell::{self};
|
||||
use crate::inline_visualization::InlineVisualizationContext;
|
||||
use crate::markdown::render_markdown_agent_with_links_cwd_and_visualizations;
|
||||
use crate::markdown::render_markdown_agent_with_list_spacing;
|
||||
use crate::markdown_render::ListSpacing;
|
||||
use crate::style::proposed_plan_style;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::prefix_hyperlink_lines;
|
||||
@@ -59,7 +60,9 @@ use super::StreamState;
|
||||
use super::prose_preview::PreviewMode;
|
||||
use super::prose_preview::ProsePreview;
|
||||
use super::render::StreamingRender;
|
||||
#[cfg(test)]
|
||||
use super::render::render_source;
|
||||
use super::render::render_source_with_list_spacing;
|
||||
use super::table_holdback::TableHoldbackScanner;
|
||||
use super::table_holdback::TableHoldbackState;
|
||||
#[cfg(test)]
|
||||
@@ -200,17 +203,18 @@ impl StreamCore {
|
||||
///
|
||||
/// This intentionally re-renders from the full raw source instead of
|
||||
/// trying to stitch together queued stable lines and the current tail. The
|
||||
/// final render is the canonical transcript representation used for
|
||||
/// consolidation, so callers that skip `reset()` can accidentally replay a
|
||||
/// finished stream into the next answer.
|
||||
/// remaining rows keep the stream's spacing so emitted offsets stay valid. Consolidation
|
||||
/// renders the returned source with the completed cell's spacing policy. Callers that skip
|
||||
/// `reset()` can accidentally replay a finished stream into the next answer.
|
||||
fn finalize_remaining(&mut self) -> (Vec<HyperlinkLine>, String) {
|
||||
let source = self.state.collector.finalize_and_take_source();
|
||||
let mut rendered = render_source(
|
||||
let mut rendered = render_source_with_list_spacing(
|
||||
&source,
|
||||
self.width,
|
||||
self.cwd.as_path(),
|
||||
self.render_mode,
|
||||
self.inline_visualization_context.as_ref(),
|
||||
self.render.list_spacing,
|
||||
);
|
||||
let remaining = rendered.split_off(self.emitted_stable_len.min(rendered.len()));
|
||||
(remaining, source)
|
||||
@@ -383,12 +387,13 @@ impl StreamCore {
|
||||
if let Some(start) = previous_tail_start.or(self.active_tail_source_start(self.render_mode))
|
||||
{
|
||||
let prefix_len = |width, mode| {
|
||||
render_source(
|
||||
render_source_with_list_spacing(
|
||||
&source[..start],
|
||||
width,
|
||||
self.cwd.as_path(),
|
||||
mode,
|
||||
self.inline_visualization_context.as_ref(),
|
||||
self.render.list_spacing,
|
||||
)
|
||||
.len()
|
||||
};
|
||||
@@ -539,11 +544,12 @@ impl StreamCore {
|
||||
|
||||
let render_start = Instant::now();
|
||||
let source = self.state.collector.committed_source();
|
||||
let stable_prefix_render = render_markdown_agent_with_links_cwd_and_visualizations(
|
||||
let stable_prefix_render = render_markdown_agent_with_list_spacing(
|
||||
&source[..source_start.min(source.len())],
|
||||
self.width,
|
||||
Some(self.cwd.as_path()),
|
||||
self.inline_visualization_context.as_ref(),
|
||||
self.render.list_spacing,
|
||||
);
|
||||
let stable_prefix_len = stable_prefix_render.len();
|
||||
tracing::trace!(
|
||||
@@ -571,6 +577,12 @@ pub(crate) struct StreamController {
|
||||
}
|
||||
|
||||
impl StreamController {
|
||||
/// Select spacing before the first delta; final source-backed cells choose their own layout.
|
||||
pub(crate) fn with_list_spacing(mut self, list_spacing: ListSpacing) -> Self {
|
||||
self.core.render.list_spacing = list_spacing;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a controller whose markdown renderer shortens local file links relative to `cwd`.
|
||||
///
|
||||
/// `width` is the content width available to markdown rendering, not necessarily the full
|
||||
@@ -697,6 +709,12 @@ pub(crate) struct PlanStreamController {
|
||||
}
|
||||
|
||||
impl PlanStreamController {
|
||||
/// Select spacing before the first delta; no list-specific holdback is needed.
|
||||
pub(crate) fn with_list_spacing(mut self, list_spacing: ListSpacing) -> Self {
|
||||
self.core.render.list_spacing = list_spacing;
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a plan-stream controller whose markdown renderer shortens local file links relative
|
||||
/// to `cwd`.
|
||||
///
|
||||
|
||||
@@ -27,8 +27,13 @@ fn unicode_math_inline_stream_preserves_display_exclusion_boundaries() {
|
||||
);
|
||||
assert_eq!(
|
||||
render.lines,
|
||||
render_streaming_markdown_agent_with_links_and_cwd(&source, Some(40), Some(&cwd))
|
||||
.lines
|
||||
render_streaming_markdown_agent_with_links_and_cwd(
|
||||
&source,
|
||||
Some(40),
|
||||
Some(&cwd),
|
||||
crate::markdown_render::ListSpacing::AfterMultiline
|
||||
)
|
||||
.lines
|
||||
);
|
||||
for width in [16, 40] {
|
||||
let mut resized = StreamingRender::new();
|
||||
@@ -44,7 +49,8 @@ fn unicode_math_inline_stream_preserves_display_exclusion_boundaries() {
|
||||
render_streaming_markdown_agent_with_links_and_cwd(
|
||||
&source,
|
||||
Some(width),
|
||||
Some(&cwd)
|
||||
Some(&cwd),
|
||||
crate::markdown_render::ListSpacing::AfterMultiline,
|
||||
)
|
||||
.lines
|
||||
);
|
||||
|
||||
@@ -168,6 +168,7 @@ fn unicode_math_rejected_display_keeps_its_closer_and_following_text() {
|
||||
&source,
|
||||
Some(40),
|
||||
Some(&cwd),
|
||||
crate::markdown_render::ListSpacing::AfterMultiline,
|
||||
);
|
||||
assert_eq!(
|
||||
(&render.lines, render.pending_math_start),
|
||||
|
||||
@@ -8,8 +8,9 @@ use crate::history_cell::HistoryRenderMode;
|
||||
use crate::history_cell::raw_lines_from_source;
|
||||
use crate::inline_visualization::InlineVisualizationContext;
|
||||
use crate::inline_visualization::contains_inline_visualization;
|
||||
use crate::markdown::render_markdown_agent_with_links_cwd_and_visualizations;
|
||||
use crate::markdown::render_markdown_agent_with_list_spacing;
|
||||
use crate::markdown::render_streaming_markdown_agent_with_links_and_cwd;
|
||||
use crate::markdown_render::ListSpacing;
|
||||
use crate::render::highlight::syntax_theme_revision;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
@@ -21,6 +22,7 @@ use std::path::Path;
|
||||
/// The prefix before both boundaries is immutable; only the final top-level Markdown block is
|
||||
/// re-rendered as committed source arrives.
|
||||
pub(super) struct StreamingRender {
|
||||
pub(super) list_spacing: ListSpacing,
|
||||
pub(super) lines: Vec<HyperlinkLine>,
|
||||
pub(super) pending_math_start: Option<usize>,
|
||||
pub(super) mermaid_start: Option<usize>,
|
||||
@@ -39,6 +41,7 @@ pub(super) struct StreamingRender {
|
||||
impl StreamingRender {
|
||||
pub(super) fn new() -> Self {
|
||||
Self {
|
||||
list_spacing: ListSpacing::AfterMultiline,
|
||||
lines: Vec::with_capacity(64),
|
||||
pending_math_start: None,
|
||||
mermaid_start: None,
|
||||
@@ -79,8 +82,12 @@ impl StreamingRender {
|
||||
self.has_inline_visualization_directive = contains_inline_visualization(source);
|
||||
self.lines = match (render_mode, inline_visualization_context) {
|
||||
(HistoryRenderMode::Rich, None) if !self.has_inline_visualization_directive => {
|
||||
let rendered =
|
||||
render_streaming_markdown_agent_with_links_and_cwd(source, width, Some(cwd));
|
||||
let rendered = render_streaming_markdown_agent_with_links_and_cwd(
|
||||
source,
|
||||
width,
|
||||
Some(cwd),
|
||||
self.list_spacing,
|
||||
);
|
||||
self.has_reference_link_definition = rendered.has_reference_link_definition;
|
||||
self.pending_math_start = rendered.pending_math_start;
|
||||
self.mermaid_start = rendered.mermaid_start;
|
||||
@@ -93,16 +100,18 @@ impl StreamingRender {
|
||||
source,
|
||||
width,
|
||||
Some(cwd),
|
||||
self.list_spacing,
|
||||
);
|
||||
self.pending_math_start = rendered.pending_math_start;
|
||||
self.mermaid_start = rendered.mermaid_start;
|
||||
}
|
||||
render_source(
|
||||
render_source_with_list_spacing(
|
||||
source,
|
||||
width,
|
||||
cwd,
|
||||
render_mode,
|
||||
inline_visualization_context,
|
||||
self.list_spacing,
|
||||
)
|
||||
}
|
||||
};
|
||||
@@ -166,8 +175,12 @@ impl StreamingRender {
|
||||
|
||||
let pending_source = &raw_source[self.stable_source_len..];
|
||||
let theme_revision = syntax_theme_revision();
|
||||
let pending =
|
||||
render_streaming_markdown_agent_with_links_and_cwd(pending_source, width, Some(cwd));
|
||||
let pending = render_streaming_markdown_agent_with_links_and_cwd(
|
||||
pending_source,
|
||||
width,
|
||||
Some(cwd),
|
||||
self.list_spacing,
|
||||
);
|
||||
self.pending_math_start = pending
|
||||
.pending_math_start
|
||||
.map(|start| self.stable_source_len + start);
|
||||
@@ -196,12 +209,13 @@ impl StreamingRender {
|
||||
let mut newly_stable_rendered_len = None;
|
||||
if let Some(boundary) = pending.last_top_level_block_start {
|
||||
let newly_stable_source = &pending_source[..boundary];
|
||||
let newly_stable = render_source(
|
||||
let newly_stable = render_source_with_list_spacing(
|
||||
newly_stable_source,
|
||||
width,
|
||||
cwd,
|
||||
render_mode,
|
||||
inline_visualization_context,
|
||||
self.list_spacing,
|
||||
);
|
||||
self.stable_source_len += boundary;
|
||||
newly_stable_rendered_len = Some(newly_stable.len());
|
||||
@@ -228,13 +242,32 @@ pub(super) fn render_source(
|
||||
cwd: &Path,
|
||||
render_mode: HistoryRenderMode,
|
||||
inline_visualization_context: Option<&InlineVisualizationContext>,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
render_source_with_list_spacing(
|
||||
source,
|
||||
width,
|
||||
cwd,
|
||||
render_mode,
|
||||
inline_visualization_context,
|
||||
ListSpacing::AfterMultiline,
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn render_source_with_list_spacing(
|
||||
source: &str,
|
||||
width: Option<usize>,
|
||||
cwd: &Path,
|
||||
render_mode: HistoryRenderMode,
|
||||
inline_visualization_context: Option<&InlineVisualizationContext>,
|
||||
list_spacing: ListSpacing,
|
||||
) -> Vec<HyperlinkLine> {
|
||||
match render_mode {
|
||||
HistoryRenderMode::Rich => render_markdown_agent_with_links_cwd_and_visualizations(
|
||||
HistoryRenderMode::Rich => render_markdown_agent_with_list_spacing(
|
||||
source,
|
||||
width,
|
||||
Some(cwd),
|
||||
inline_visualization_context,
|
||||
list_spacing,
|
||||
),
|
||||
HistoryRenderMode::Raw => plain_hyperlink_lines(raw_lines_from_source(source)),
|
||||
}
|
||||
|
||||
@@ -168,6 +168,7 @@ fn growing_single_top_level_blocks_render_and_scan_in_one_pass() {
|
||||
&source,
|
||||
width,
|
||||
Some(cwd.as_path()),
|
||||
crate::markdown_render::ListSpacing::AfterMultiline,
|
||||
);
|
||||
assert_eq!(pending.last_top_level_block_start, None);
|
||||
assert_eq!(render.lines, pending.lines);
|
||||
@@ -200,6 +201,59 @@ fn incremental_raw_render_preserves_blank_lines() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_list_spacing_keeps_incremental_and_full_renders_aligned() {
|
||||
use crate::markdown_render::ListSpacing;
|
||||
|
||||
let cwd = test_cwd();
|
||||
for mode in [HistoryRenderMode::Rich, HistoryRenderMode::Raw] {
|
||||
let mut render = StreamingRender::new();
|
||||
render.list_spacing = ListSpacing::Compact;
|
||||
let mut source = String::new();
|
||||
for chunk in [
|
||||
"- Short\n",
|
||||
"- A sibling with enough words to wrap across rows\n",
|
||||
"- Last\n",
|
||||
"\nAfter the list.\n\n",
|
||||
"| Name | Value |\n| --- | --- |\n| A | B |\n",
|
||||
"\n- Nested\n - One\n - Two\n- End\n",
|
||||
] {
|
||||
append(&mut render, &mut source, chunk, Some(24), &cwd, mode);
|
||||
assert_eq!(
|
||||
render.lines,
|
||||
super::render_source_with_list_spacing(
|
||||
&source,
|
||||
Some(24),
|
||||
&cwd,
|
||||
mode,
|
||||
/*inline_visualization_context*/ None,
|
||||
ListSpacing::Compact,
|
||||
)
|
||||
);
|
||||
}
|
||||
for width in [80, 24] {
|
||||
render.recompute(
|
||||
&source,
|
||||
Some(width),
|
||||
&cwd,
|
||||
mode,
|
||||
/*inline_visualization_context*/ None,
|
||||
);
|
||||
assert_eq!(
|
||||
render.lines,
|
||||
super::render_source_with_list_spacing(
|
||||
&source,
|
||||
Some(width),
|
||||
&cwd,
|
||||
mode,
|
||||
/*inline_visualization_context*/ None,
|
||||
ListSpacing::Compact,
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_visualization_context_without_directives_keeps_stable_prefix() {
|
||||
let cwd = test_cwd();
|
||||
|
||||
@@ -154,10 +154,8 @@ impl TranscriptView {
|
||||
width,
|
||||
expanded,
|
||||
)
|
||||
} else if detailed {
|
||||
TextLayout::new(cell.transcript_hyperlink_lines(width), width)
|
||||
} else if mode == HistoryRenderMode::Rich {
|
||||
TextLayout::new(cell.compact_hyperlink_lines(width), width)
|
||||
} else if detailed || mode == HistoryRenderMode::Rich {
|
||||
TextLayout::new(cell.retained_hyperlink_lines(width, detailed), width)
|
||||
} else {
|
||||
TextLayout::new(cell.display_hyperlink_lines_for_mode(width, mode), width)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user