diff --git a/codex-rs/tui/src/app/agent_message_consolidation.rs b/codex-rs/tui/src/app/agent_message_consolidation.rs index dfa3a38ea1..4b8120185d 100644 --- a/codex-rs/tui/src/app/agent_message_consolidation.rs +++ b/codex-rs/tui/src/app/agent_message_consolidation.rs @@ -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(); } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index ad2aa6b557..7f1503567d 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -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(); } diff --git a/codex-rs/tui/src/app/owned_transcript_tests.rs b/codex-rs/tui/src/app/owned_transcript_tests.rs index 20f8bd69c5..1d3cd14151 100644 --- a/codex-rs/tui/src/app/owned_transcript_tests.rs +++ b/codex-rs/tui/src/app/owned_transcript_tests.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/streaming.rs b/codex-rs/tui/src/chatwidget/streaming.rs index 3c907a584a..d1d17f8cd5 100644 --- a/codex-rs/tui/src/chatwidget/streaming.rs +++ b/codex-rs/tui/src/chatwidget/streaming.rs @@ -4,6 +4,7 @@ //! cells, commit ticks, and interrupt deferral. use super::*; +use crate::markdown_render::ListSpacing; fn latest_summary_line(text: &str) -> Option { 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 diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index d3edcf3133..c8b7d20364 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -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; diff --git a/codex-rs/tui/src/chatwidget/tests/list_spacing_tests.rs b/codex-rs/tui/src/chatwidget/tests/list_spacing_tests.rs new file mode 100644 index 0000000000..e3df19ed23 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/list_spacing_tests.rs @@ -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); + } + 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); + } + _ => {} + } + } + let completed = completed.expect("termination must retain source for reflow"); + let text = |lines: Vec>| { + lines + .iter() + .map(|line| line.to_string().trim_end().to_owned()) + .collect::>() + .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")); +} diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__list_spacing_tests__list_spacing_streams_compact_then_reflows_on_completion_or_interruption.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__list_spacing_tests__list_spacing_streams_compact_then_reflows_on_completion_or_interruption.snap new file mode 100644 index 0000000000..f3e5ac41ce --- /dev/null +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__list_spacing_tests__list_spacing_streams_compact_then_reflows_on_completion_or_interruption.snap @@ -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 diff --git a/codex-rs/tui/src/history_cell/markdown_render_cache.rs b/codex-rs/tui/src/history_cell/markdown_render_cache.rs index 95e4f6be39..39b1ff7c6f 100644 --- a/codex-rs/tui/src/history_cell/markdown_render_cache.rs +++ b/codex-rs/tui/src/history_cell/markdown_render_cache.rs @@ -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, ) -> Vec { 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(), diff --git a/codex-rs/tui/src/history_cell/messages.rs b/codex-rs/tui/src/history_cell/messages.rs index 3d9e4681f8..1bdc477c0b 100644 --- a/codex-rs/tui/src/history_cell/messages.rs +++ b/codex-rs/tui/src/history_cell/messages.rs @@ -572,12 +572,12 @@ fn normalize_whitespace_only_hyperlink_lines(mut lines: Vec) -> V lines } -impl HistoryCell for AgentMarkdownCell { - fn display_lines(&self, width: u16) -> Vec> { - visible_lines(self.display_hyperlink_lines(width)) - } - - fn display_hyperlink_lines(&self, width: u16) -> Vec { +impl AgentMarkdownCell { + fn render_lines( + &self, + width: u16, + list_spacing: crate::markdown_render::ListSpacing, + ) -> Vec { 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> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + self.render_lines(width, crate::markdown_render::ListSpacing::AfterMultiline) + } + + fn retained_hyperlink_lines(&self, width: u16, _detailed: bool) -> Vec { + self.render_lines(width, crate::markdown_render::ListSpacing::Uniform) + } fn transcript_hyperlink_lines(&self, width: u16) -> Vec { self.display_hyperlink_lines(width) diff --git a/codex-rs/tui/src/history_cell/mod.rs b/codex-rs/tui/src/history_cell/mod.rs index 92800190dd..5ff39b99c1 100644 --- a/codex-rs/tui/src/history_cell/mod.rs +++ b/codex-rs/tui/src/history_cell/mod.rs @@ -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 { + 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 { diff --git a/codex-rs/tui/src/history_cell/plans.rs b/codex-rs/tui/src/history_cell/plans.rs index 8cd272d8ce..7f68f5c7b9 100644 --- a/codex-rs/tui/src/history_cell/plans.rs +++ b/codex-rs/tui/src/history_cell/plans.rs @@ -113,13 +113,13 @@ pub(crate) struct ProposedPlanStreamCell { is_stream_continuation: bool, } -impl HistoryCell for ProposedPlanCell { - fn display_lines(&self, width: u16) -> Vec> { - visible_lines(self.display_hyperlink_lines(width)) - } - - fn display_hyperlink_lines(&self, width: u16) -> Vec { - self.rendered_lines.render(width, || { +impl ProposedPlanCell { + fn render_lines( + &self, + width: u16, + list_spacing: crate::markdown_render::ListSpacing, + ) -> Vec { + 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> { + visible_lines(self.display_hyperlink_lines(width)) + } + + fn display_hyperlink_lines(&self, width: u16) -> Vec { + self.render_lines(width, crate::markdown_render::ListSpacing::AfterMultiline) + } + + fn retained_hyperlink_lines(&self, width: u16, _detailed: bool) -> Vec { + self.render_lines(width, crate::markdown_render::ListSpacing::Uniform) + } fn transcript_hyperlink_lines(&self, width: u16) -> Vec { self.display_hyperlink_lines(width) diff --git a/codex-rs/tui/src/markdown.rs b/codex-rs/tui/src/markdown.rs index 995f57f917..240cadb538 100644 --- a/codex-rs/tui/src/markdown.rs +++ b/codex-rs/tui/src/markdown.rs @@ -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, cwd: Option<&Path>, ) -> Vec { - 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, cwd: Option<&Path>, inline_visualization_context: Option<&InlineVisualizationContext>, + list_spacing: ListSpacing, ) -> Vec { 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, 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 diff --git a/codex-rs/tui/src/markdown_render.rs b/codex-rs/tui/src/markdown_render.rs index b028fa7f2b..3ab4574ef6 100644 --- a/codex-rs/tui/src/markdown_render.rs +++ b/codex-rs/tui/src/markdown_render.rs @@ -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>, list_needs_blank_before_next_item: Vec, list_item_start_line_counts: Vec, + list_spacing: ListSpacing, + uniform_lists: Vec, link: Option, 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, indent: Option>) { 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; diff --git a/codex-rs/tui/src/markdown_render/list_spacing.rs b/codex-rs/tui/src/markdown_render/list_spacing.rs new file mode 100644 index 0000000000..86d39f2c77 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/list_spacing.rs @@ -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, +} + +impl UniformList { + pub(super) fn finish(self, lines: &mut Vec) { + 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) + } + })); + } +} diff --git a/codex-rs/tui/src/markdown_render/list_spacing_tests.rs b/codex-rs/tui/src/markdown_render/list_spacing_tests.rs new file mode 100644 index 0000000000..a3ffb641e8 --- /dev/null +++ b/codex-rs/tui/src/markdown_render/list_spacing_tests.rs @@ -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::>() + .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::>(), + uniform.iter().map(|line| &line.source).collect::>() + ); +} diff --git a/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__list_spacing_tests__list_spacing_is_uniform_per_list_and_preserves_item_paragraphs.snap b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__list_spacing_tests__list_spacing_is_uniform_per_list_and_preserves_item_paragraphs.snap new file mode 100644 index 0000000000..81b875e44e --- /dev/null +++ b/codex-rs/tui/src/markdown_render/snapshots/codex_tui__markdown_render__list_spacing_tests__list_spacing_is_uniform_per_list_and_preserves_item_paragraphs.snap @@ -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 diff --git a/codex-rs/tui/src/markdown_render/streaming.rs b/codex-rs/tui/src/markdown_render/streaming.rs index c6978e34ee..3bf0123c08 100644 --- a/codex-rs/tui/src/markdown_render/streaming.rs +++ b/codex-rs/tui/src/markdown_render/streaming.rs @@ -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, 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, diff --git a/codex-rs/tui/src/markdown_render/web_links_tests.rs b/codex-rs/tui/src/markdown_render/web_links_tests.rs index 2657b863a3..7869cc2715 100644 --- a/codex-rs/tui/src/markdown_render/web_links_tests.rs +++ b/codex-rs/tui/src/markdown_render/web_links_tests.rs @@ -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)); } diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index c16433dadc..43778a0857 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -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::>(); + let mut lines = render_assistant( + &line.text, + /*width*/ None, + cwd, + &|_| false, + crate::markdown_render::ListSpacing::AfterMultiline, + ) + .lines + .into_iter() + .map(|line| line.line) + .collect::>(); for line in &mut lines { *line = conversation_content_line(line.clone(), conversation_assistant_style()); } diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index d7085ae2b6..660f3e984a 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -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, 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`. /// diff --git a/codex-rs/tui/src/streaming/inline_math_tests.rs b/codex-rs/tui/src/streaming/inline_math_tests.rs index 7da430c6f0..bc598985c8 100644 --- a/codex-rs/tui/src/streaming/inline_math_tests.rs +++ b/codex-rs/tui/src/streaming/inline_math_tests.rs @@ -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 ); diff --git a/codex-rs/tui/src/streaming/math_tests.rs b/codex-rs/tui/src/streaming/math_tests.rs index 3226278f12..ca4019959f 100644 --- a/codex-rs/tui/src/streaming/math_tests.rs +++ b/codex-rs/tui/src/streaming/math_tests.rs @@ -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), diff --git a/codex-rs/tui/src/streaming/render.rs b/codex-rs/tui/src/streaming/render.rs index 41dab92575..9fa26f7a8f 100644 --- a/codex-rs/tui/src/streaming/render.rs +++ b/codex-rs/tui/src/streaming/render.rs @@ -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, pub(super) pending_math_start: Option, pub(super) mermaid_start: Option, @@ -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 { + 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, + cwd: &Path, + render_mode: HistoryRenderMode, + inline_visualization_context: Option<&InlineVisualizationContext>, + list_spacing: ListSpacing, ) -> Vec { 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)), } diff --git a/codex-rs/tui/src/streaming/render_tests.rs b/codex-rs/tui/src/streaming/render_tests.rs index 2a0bbc54a0..255399bc00 100644 --- a/codex-rs/tui/src/streaming/render_tests.rs +++ b/codex-rs/tui/src/streaming/render_tests.rs @@ -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(); diff --git a/codex-rs/tui/src/transcript_view/layout.rs b/codex-rs/tui/src/transcript_view/layout.rs index afefd6caef..fe66fb9b91 100644 --- a/codex-rs/tui/src/transcript_view/layout.rs +++ b/codex-rs/tui/src/transcript_view/layout.rs @@ -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) }