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:
Felipe Coury
2026-09-20 22:55:19 +00:00
committed by copyberry
parent d99213289f
commit 4ca8e7afff
25 changed files with 789 additions and 68 deletions
@@ -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();
}
+6 -2
View File
@@ -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;
+30 -11
View File
@@ -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
+3
View File
@@ -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"));
}
@@ -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(),
+23 -8
View File
@@ -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)
+9
View File
@@ -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> {
+24 -8
View File
@@ -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)
+9 -2
View File
@@ -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
+52 -6
View File
@@ -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<_>>()
);
}
@@ -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));
}
+11 -5
View File
@@ -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());
}
+25 -7
View File
@@ -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
);
+1
View File
@@ -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),
+41 -8
View File
@@ -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();
+2 -4
View File
@@ -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)
}