Compare commits

...

20 Commits

Author SHA1 Message Date
Felipe Coury
887947d7a8 fix(tui): stream VTE history rows into scrollback 2026-05-03 16:07:14 -03:00
Felipe Coury
47fafe6836 codex: address PR review feedback (#20252) 2026-05-02 17:00:22 -03:00
Felipe Coury
25c7f8e0e8 fix(tui): keep inline-code table pipes in cells 2026-05-02 16:36:01 -03:00
Felipe Coury
1db80892bc codex: address PR review feedback (#20252) 2026-05-01 18:20:40 -03:00
Felipe Coury
6cc4173121 codex: address PR review feedback (#20252) 2026-05-01 18:03:44 -03:00
Felipe Coury
c32994513b codex: address PR review feedback (#20252) 2026-05-01 17:37:48 -03:00
Felipe Coury
280d5841df codex: fix CI failure on PR #20252 2026-05-01 17:30:55 -03:00
Felipe Coury
916e73853d fix(tui): simplify markdown table fallback 2026-05-01 17:00:21 -03:00
Felipe Coury
a60ad245a3 fix(tui): reflow streamed markdown tables from source 2026-05-01 15:50:24 -03:00
Felipe Coury
fc40b0394e fix(tui): keep narrow markdown tables boxed when readable 2026-05-01 15:50:17 -03:00
Felipe Coury
939a9ffd37 fix(tui): preserve markdown styles in tables 2026-04-30 13:24:00 -03:00
Felipe Coury
a0b16c1e1e fix(tui): replay history during resize reflow 2026-04-29 21:16:09 -03:00
Felipe Coury
5a645f40dd fix(tui): reflow scrollback immediately on resize 2026-04-29 17:57:16 -03:00
Felipe Coury
29458259c0 fix(tui): clear resize repaint artifacts 2026-04-29 17:46:10 -03:00
Felipe Coury
48d8256006 fix(tui): keep markdown tables off wrap column 2026-04-29 17:29:31 -03:00
Felipe Coury
2f948bac03 fix(tui): box vertical markdown table fallback 2026-04-29 17:09:40 -03:00
Felipe Coury
284a928aa8 feat(tui): add readable table fallback 2026-04-29 17:01:19 -03:00
Felipe Coury
e73760e90c fix(tui): preserve streamed markdown order 2026-04-29 16:22:14 -03:00
Felipe Coury
dd00158caa fix(tui): keep markdown table links inline 2026-04-29 16:00:19 -03:00
Felipe Coury
927c909004 feat(tui): render responsive markdown tables 2026-04-29 15:55:00 -03:00
19 changed files with 3488 additions and 277 deletions

View File

@@ -5,27 +5,33 @@
//! the stored cells as source, clears the Codex-owned terminal history, and re-emits the transcript
//! for the new terminal size.
//!
//! Streaming output is the fragile part of this lifecycle. Active streams first appear as transient
//! stream cells, then consolidate into source-backed finalized cells. Resize work that happens
//! before consolidation is marked as stream-time work so consolidation can force one final rebuild
//! from the finalized source.
//! Streaming output is the fragile part of this lifecycle. Active streams first appear as stream
//! cells that may carry the latest fully-emitted stable markdown source, then consolidate into
//! source-backed finalized cells. Resize work that happens before consolidation uses that stable
//! source when available and is still marked as stream-time work so consolidation can force one
//! final rebuild from the finalized source.
//!
//! The row cap is enforced while rendering from `HistoryCell` source, not after writing to the
//! terminal. Initial resume replay uses the same display-line buffering contract so large sessions
//! do not write more retained rows than resize replay would later be willing to rebuild.
use std::collections::VecDeque;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use codex_features::Feature;
use color_eyre::eyre::Result;
use ratatui::prelude::Stylize;
use ratatui::text::Line;
use super::App;
use super::InitialHistoryReplayBuffer;
use crate::history_cell;
use crate::history_cell::HistoryCell;
use crate::markdown::append_markdown;
use crate::render::line_utils::prefix_lines;
use crate::style::proposed_plan_style;
use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE;
use crate::tui;
@@ -34,6 +40,18 @@ struct ReflowCellDisplay {
is_stream_continuation: bool,
}
struct AgentMessageStreamSource {
source: String,
cwd: PathBuf,
is_first_line: bool,
}
struct ProposedPlanStreamSource {
source: String,
cwd: PathBuf,
include_bottom_padding: bool,
}
/// Rendered transcript lines ready to be replayed into terminal scrollback.
///
/// This is intentionally line-oriented rather than cell-oriented because the terminal only accepts
@@ -177,29 +195,10 @@ impl App {
}
}
fn schedule_resize_reflow(&mut self, target_width: Option<u16>) -> bool {
debug_assert!(self.terminal_resize_reflow_enabled());
self.transcript_reflow.schedule_debounced(target_width)
}
fn resize_reflow_max_rows(&self) -> Option<usize> {
crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow)
}
fn clear_terminal_for_resize_replay(&mut self, tui: &mut tui::Tui) -> Result<()> {
if tui.is_alt_screen_active() {
tui.terminal.clear_visible_screen()?;
} else {
tui.terminal.clear_scrollback_and_visible_screen_ansi()?;
}
let mut area = tui.terminal.viewport_area;
if area.y > 0 {
area.y = 0;
tui.terminal.set_viewport_area(area);
}
Ok(())
}
/// Finish stream consolidation by repairing any resize work that happened during streaming.
///
/// This is called after agent-message stream cells have either been replaced by an
@@ -273,12 +272,11 @@ impl App {
if reflow_needed && self.should_mark_reflow_as_stream_time() {
self.transcript_reflow.mark_resize_requested_during_stream();
}
let target_width = reflow_needed.then_some(size.width);
if self.schedule_resize_reflow(target_width) {
frame_requester.schedule_frame();
} else {
frame_requester.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE);
}
// Reflow immediately on the draw that observes the resize. Leaving a debounced
// gap lets the terminal emulator's native scrollback reflow show stale rows under
// Codex's next table repaint.
self.transcript_reflow.schedule_immediate();
frame_requester.schedule_frame();
} else if !self.terminal_resize_reflow_enabled() && width.changed {
self.transcript_reflow.clear();
}
@@ -326,7 +324,7 @@ impl App {
Ok(())
}
/// Run a pending transcript reflow when its debounce deadline has arrived.
/// Run a pending transcript reflow once it is due.
///
/// Reflow is deferred while an overlay is active because the overlay owns the current draw
/// surface. Callers must keep using `HistoryCell` source as the rebuild input; attempting to
@@ -381,6 +379,7 @@ impl App {
if self.transcript_cells.is_empty() {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
tui.clear_pending_history_lines();
tui.replay_history_lines_after_resize(Vec::new());
self.reset_history_emission_state();
return Ok(width);
}
@@ -390,12 +389,9 @@ impl App {
// Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells.
tui.clear_pending_history_lines();
self.clear_terminal_for_resize_replay(tui)?;
self.deferred_history_lines.clear();
if !reflowed_lines.is_empty() {
tui.insert_history_lines(reflowed_lines);
}
tui.replay_history_lines_after_resize(reflowed_lines);
Ok(width)
}
@@ -413,6 +409,34 @@ impl App {
let mut rendered_rows = 0usize;
let mut start = self.transcript_cells.len();
let plan_stream_run_start =
trailing_run_start::<history_cell::ProposedPlanStreamCell>(&self.transcript_cells);
if let Some(displays) = self.trailing_proposed_plan_stream_displays(
plan_stream_run_start,
self.transcript_cells.len(),
width,
) {
start = plan_stream_run_start;
for display in displays.into_iter().rev() {
rendered_rows += display.lines.len();
cell_displays.push_front(display);
}
} else {
let agent_stream_run_start =
trailing_run_start::<history_cell::AgentMessageCell>(&self.transcript_cells);
if let Some(displays) = self.trailing_agent_message_stream_displays(
agent_stream_run_start,
self.transcript_cells.len(),
width,
) {
start = agent_stream_run_start;
for display in displays.into_iter().rev() {
rendered_rows += display.lines.len();
cell_displays.push_front(display);
}
}
}
while start > 0 {
start -= 1;
let cell = self.transcript_cells[start].clone();
@@ -466,6 +490,95 @@ impl App {
}
}
fn trailing_agent_message_stream_displays(
&self,
start: usize,
end: usize,
width: u16,
) -> Option<Vec<ReflowCellDisplay>> {
if start == end {
return None;
}
let first_agent_cell = self.transcript_cells[start]
.as_any()
.downcast_ref::<history_cell::AgentMessageCell>()?;
let is_first_line = first_agent_cell.is_first_line();
let mut latest_source = None;
let mut latest_source_offset = 0;
for (offset, cell) in self.transcript_cells[start..end].iter().enumerate() {
let agent_cell = cell
.as_any()
.downcast_ref::<history_cell::AgentMessageCell>()?;
if let Some((source, cwd)) = agent_cell.markdown_source() {
latest_source = Some(AgentMessageStreamSource {
source: source.to_string(),
cwd: cwd.to_path_buf(),
is_first_line,
});
latest_source_offset = offset;
}
}
let latest_source = latest_source?;
let mut displays = vec![ReflowCellDisplay {
lines: render_agent_message_stream_source(&latest_source, width),
is_stream_continuation: !latest_source.is_first_line,
}];
for cell in &self.transcript_cells[start + latest_source_offset + 1..end] {
displays.push(ReflowCellDisplay {
lines: cell.display_lines(width),
is_stream_continuation: cell.is_stream_continuation(),
});
}
Some(displays)
}
fn trailing_proposed_plan_stream_displays(
&self,
start: usize,
end: usize,
width: u16,
) -> Option<Vec<ReflowCellDisplay>> {
if start == end {
return None;
}
let mut latest_source = None;
let mut latest_source_offset = 0;
for (offset, cell) in self.transcript_cells[start..end].iter().enumerate() {
let plan_cell = cell
.as_any()
.downcast_ref::<history_cell::ProposedPlanStreamCell>()?;
if let Some((source, cwd, include_bottom_padding)) = plan_cell.markdown_source() {
latest_source = Some(ProposedPlanStreamSource {
source: source.to_string(),
cwd: cwd.to_path_buf(),
include_bottom_padding,
});
latest_source_offset = offset;
}
}
let latest_source = latest_source?;
let mut displays = vec![ReflowCellDisplay {
lines: render_proposed_plan_stream_source(&latest_source, width),
is_stream_continuation: false,
}];
for cell in &self.transcript_cells[start + latest_source_offset + 1..end] {
displays.push(ReflowCellDisplay {
lines: cell.display_lines(width),
is_stream_continuation: cell.is_stream_continuation(),
});
}
Some(displays)
}
/// Return whether current transcript state should be treated as stream-time resize state.
///
/// The active stream controllers cover normal streaming. The trailing-cell checks cover the
@@ -480,3 +593,68 @@ impl App {
< self.transcript_cells.len()
}
}
fn render_agent_message_stream_source(
stream_source: &AgentMessageStreamSource,
width: u16,
) -> Vec<Line<'static>> {
let Some(wrap_width) = crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2)
else {
return prefix_lines(
vec![Line::default()],
if stream_source.is_first_line {
"".dim()
} else {
" ".into()
},
" ".into(),
);
};
let mut lines = Vec::new();
crate::markdown::append_markdown(
&stream_source.source,
Some(wrap_width),
Some(stream_source.cwd.as_path()),
&mut lines,
);
prefix_lines(
lines,
if stream_source.is_first_line {
"".dim()
} else {
" ".into()
},
" ".into(),
)
}
fn render_proposed_plan_stream_source(
stream_source: &ProposedPlanStreamSource,
width: u16,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = vec![vec!["".dim(), "Proposed Plan".bold()].into()];
lines.push(Line::from(" "));
let mut body = Vec::new();
let wrap_width = width.saturating_sub(4).max(1) as usize;
append_markdown(
&stream_source.source,
Some(wrap_width),
Some(stream_source.cwd.as_path()),
&mut body,
);
if body.is_empty() {
body.push(Line::from("(empty)".dim().italic()));
}
let plan_style = proposed_plan_style();
let mut plan_lines: Vec<Line<'static>> = vec![Line::from(" ")];
plan_lines.extend(prefix_lines(body, " ".into(), " ".into()));
if stream_source.include_bottom_padding {
plan_lines.push(Line::from(" "));
}
lines.extend(plan_lines.into_iter().map(|line| line.style(plan_style)));
lines
}

View File

@@ -13,10 +13,14 @@ use crate::chatwidget::tests::make_chatwidget_manual_with_sender;
use crate::chatwidget::tests::set_chatgpt_auth;
use crate::chatwidget::tests::set_fast_mode_test_catalog;
use crate::file_search::FileSearchManager;
use crate::history_cell::AgentMarkdownCell;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PlainHistoryCell;
use crate::history_cell::UserHistoryCell;
use crate::history_cell::new_proposed_plan;
use crate::history_cell::new_proposed_plan_stream;
use crate::history_cell::new_proposed_plan_stream_with_markdown_source;
use crate::history_cell::new_session_info;
use crate::multi_agents::AgentPickerThreadEntry;
use assert_matches::assert_matches;
@@ -95,6 +99,7 @@ use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use tempfile::tempdir;
use tokio::time;
use unicode_width::UnicodeWidthStr;
macro_rules! assert_app_snapshot {
($name:expr, $value:expr $(,)?) => {
@@ -3837,6 +3842,17 @@ fn rendered_line_text(line: &Line<'static>) -> String {
.collect()
}
fn markdown_table_source() -> &'static str {
"| Area | Result |\n| --- | --- |\n| Streaming resize | This cell contains enough prose to wrap differently across terminal widths while staying in table form. |\n| Scrollback preservation | SENTINEL_TABLE_VALUE_WITH_LONG_UNBREAKABLE_TOKEN |\n"
}
fn markdown_table_cell() -> Arc<dyn HistoryCell> {
Arc::new(AgentMarkdownCell::new(
markdown_table_source().to_string(),
&std::env::temp_dir(),
)) as Arc<dyn HistoryCell>
}
#[tokio::test]
async fn capped_resize_reflow_renders_recent_suffix_only() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
@@ -3905,6 +3921,225 @@ async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() {
);
}
#[tokio::test]
async fn table_resize_lifecycle_reflow_preserves_scrollback_and_rerenders_width() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
app.transcript_cells = vec![
plain_line_cell("BEFORE_TABLE_SENTINEL"),
markdown_table_cell(),
plain_line_cell("AFTER_TABLE_SENTINEL"),
];
let narrow = app.render_transcript_lines_for_reflow(/*width*/ 44);
let wide = app.render_transcript_lines_for_reflow(/*width*/ 96);
let narrow_text = narrow
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
let wide_text = wide
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
for lines in [&narrow_text, &wide_text] {
assert_eq!(
lines
.iter()
.filter(|line| line.contains("BEFORE_TABLE_SENTINEL"))
.count(),
1
);
assert_eq!(
lines
.iter()
.filter(|line| line.contains("AFTER_TABLE_SENTINEL"))
.count(),
1
);
assert!(
lines.iter().any(|line| line.contains('┌')),
"expected finalized scrollback table to keep box shape after reflow: {lines:?}",
);
}
assert!(
narrow_text
.iter()
.filter(|line| line.contains('│') || line.contains('─'))
.all(|line| line.width() <= 44),
"narrow table lines must fit resized scrollback width: {narrow_text:?}",
);
assert!(
narrow_text.len() > wide_text.len(),
"expanded scrollback reflow should reduce table wrapping\nnarrow={narrow_text:?}\nwide={wide_text:?}",
);
}
#[tokio::test]
async fn table_resize_lifecycle_stream_reflow_uses_markdown_source_not_transient_table_rows() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
let cwd = std::env::temp_dir();
let source = markdown_table_source();
let wide_body_width =
crate::width::usable_content_width_u16(/*total_width*/ 96, /*reserved_cols*/ 2)
.expect("wide terminal width should leave markdown body room");
let mut controller =
crate::streaming::controller::StreamController::new(Some(wide_body_width), cwd.as_path());
assert!(controller.push(&format!(
"{source}\nTail paragraph keeps the preceding table stable.\n"
)));
loop {
let (cell, idle) = controller.on_commit_tick();
if let Some(cell) = cell {
app.transcript_cells.push(cell.into());
}
if idle {
break;
}
}
assert!(
app.transcript_cells.len() > 1,
"stream should emit stable table lines over multiple transient cells",
);
let reflowed_from_transient = app.render_transcript_lines_for_reflow(/*width*/ 44);
let source_backed = AgentMarkdownCell::new(source.to_string(), cwd.as_path());
let expected_source_backed = source_backed.display_lines(/*width*/ 44);
assert_eq!(
reflowed_from_transient
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
expected_source_backed
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>(),
"resize reflow during streaming must rebuild tables from markdown source, not re-wrap stale transient table rows",
);
}
#[tokio::test]
async fn table_resize_lifecycle_stream_reflow_preserves_source_prefix_before_unsourced_tail() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
let cwd = std::env::temp_dir();
let source = markdown_table_source();
app.transcript_cells
.push(Arc::new(AgentMessageCell::new_with_markdown_source(
vec![Line::from("│ Area │ Result │")],
/*is_first_line*/ true,
source.to_string(),
cwd.as_path(),
)) as Arc<dyn HistoryCell>);
app.transcript_cells.push(Arc::new(AgentMessageCell::new(
vec![Line::from("newer emitted stream row")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>);
let reflowed = app.render_transcript_lines_for_reflow(/*width*/ 44);
let lines = reflowed
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
let mut expected = AgentMarkdownCell::new(source.to_string(), cwd.as_path())
.display_lines(/*width*/ 44)
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
expected.push(" newer emitted stream row".to_string());
assert_eq!(
lines, expected,
"resize reflow must rebuild the source-backed prefix and append newer unsourced stream cells",
);
}
#[tokio::test]
async fn table_resize_lifecycle_plan_stream_reflow_uses_markdown_source_before_unsourced_tail() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
let cwd = std::env::temp_dir();
let source = markdown_table_source();
app.transcript_cells
.push(Arc::new(new_proposed_plan_stream_with_markdown_source(
vec![Line::from("│ Area │ Result │")],
/*is_stream_continuation*/ false,
source.to_string(),
cwd.as_path(),
/*include_bottom_padding*/ false,
)) as Arc<dyn HistoryCell>);
app.transcript_cells.push(Arc::new(new_proposed_plan_stream(
vec![Line::from(" newer emitted plan row")],
/*is_stream_continuation*/ true,
)) as Arc<dyn HistoryCell>);
let reflowed = app.render_transcript_lines_for_reflow(/*width*/ 44);
let lines = reflowed
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
let mut expected = new_proposed_plan(source.to_string(), cwd.as_path())
.display_lines(/*width*/ 44)
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
expected.pop();
expected.push(" newer emitted plan row".to_string());
assert_eq!(
lines, expected,
"resize reflow must rebuild proposed-plan stream tables from source and append newer unsourced plan cells",
);
}
#[tokio::test]
async fn table_resize_lifecycle_stream_reflow_does_not_drop_newer_unsourced_tail() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
enable_terminal_resize_reflow(&mut app);
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
let cwd = std::env::temp_dir();
app.transcript_cells
.push(Arc::new(AgentMessageCell::new_with_markdown_source(
vec![Line::from("│ Area │ Result │")],
/*is_first_line*/ true,
markdown_table_source().to_string(),
cwd.as_path(),
)) as Arc<dyn HistoryCell>);
app.transcript_cells.push(Arc::new(AgentMessageCell::new(
vec![Line::from("newer emitted stream row")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>);
let reflowed = app.render_transcript_lines_for_reflow(/*width*/ 44);
let lines = reflowed
.lines
.iter()
.map(rendered_line_text)
.collect::<Vec<_>>();
assert!(
lines
.iter()
.any(|line| line.contains("newer emitted stream row")),
"resize reflow must not replace newer emitted stream cells with an older source snapshot: {lines:?}",
);
}
#[tokio::test]
async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
@@ -3944,6 +4179,7 @@ async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() {
async fn height_shrink_schedules_resize_reflow() {
let (mut app, _rx, _op_rx) = make_test_app_with_channels().await;
enable_terminal_resize_reflow(&mut app);
app.transcript_cells = vec![plain_line_cell("resize source")];
let frame_requester = crate::tui::FrameRequester::test_dummy();
assert!(!app.handle_draw_size_change(
@@ -3958,6 +4194,10 @@ async fn height_shrink_schedules_resize_reflow() {
&frame_requester,
));
assert!(app.transcript_reflow.has_pending_reflow());
assert!(
app.transcript_reflow
.pending_is_due(std::time::Instant::now())
);
}
fn test_turn(turn_id: &str, status: TurnStatus, items: Vec<ThreadItem>) -> Turn {

View File

@@ -2039,6 +2039,10 @@ impl ChatWidget {
fn flush_answer_stream_with_separator(&mut self) {
let had_stream_controller = self.stream_controller.is_some();
if had_stream_controller {
self.active_cell = None;
self.bump_active_cell_revision();
}
if let Some(mut controller) = self.stream_controller.take() {
let (cell, source) = controller.finalize();
if let Some(cell) = cell {
@@ -2627,9 +2631,9 @@ impl ChatWidget {
self.plan_delta_buffer.push_str(&delta);
// Before streaming plan content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
if self.plan_stream_controller.is_none() {
self.flush_active_cell();
self.plan_stream_controller = Some(PlanStreamController::new(
self.current_stream_width(/*reserved_cols*/ 4),
&self.config.cwd,
@@ -2641,6 +2645,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_plan_stream_active_tail();
self.request_redraw();
}
@@ -2661,6 +2666,10 @@ impl ChatWidget {
self.plan_delta_buffer.clear();
self.plan_item_active = false;
self.saw_plan_item_this_turn = true;
if self.plan_stream_controller.is_some() {
self.active_cell = None;
self.bump_active_cell_revision();
}
let (finalized_streamed_cell, consolidated_plan_source) =
if let Some(mut controller) = self.plan_stream_controller.take() {
controller.finalize()
@@ -4762,10 +4771,19 @@ impl ChatWidget {
scope,
now,
);
let emitted_cells = !outcome.cells.is_empty();
for cell in outcome.cells {
self.bottom_pane.hide_status_indicator();
self.add_boxed_history(cell);
}
if emitted_cells {
if self.stream_controller.is_some() {
self.sync_agent_stream_active_tail();
}
if self.plan_stream_controller.is_some() {
self.sync_plan_stream_active_tail();
}
}
if outcome.has_controller && outcome.all_idle {
self.maybe_restore_status_indicator_after_stream_idle();
@@ -4811,8 +4829,10 @@ impl ChatWidget {
#[inline]
fn handle_streaming_delta(&mut self, delta: String) {
// Before streaming agent content, flush any active exec cell group.
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
if self.stream_controller.is_none() {
self.flush_unified_exec_wait_streak();
self.flush_active_cell();
}
if self.stream_controller.is_none() {
// If the previous turn inserted non-stream history (exec output, patch status, MCP
@@ -4837,6 +4857,7 @@ impl ChatWidget {
self.app_event_tx.send(AppEvent::StartCommitAnimation);
self.run_catch_up_commit_tick();
}
self.sync_agent_stream_active_tail();
self.request_redraw();
}
@@ -5895,7 +5916,11 @@ impl ChatWidget {
.as_ref()
.is_some_and(|c| c.as_any().is::<history_cell::SessionHeaderHistoryCell>());
if !keep_placeholder_header_active && !cell.display_lines(u16::MAX).is_empty() {
if !keep_placeholder_header_active
&& self.stream_controller.is_none()
&& self.plan_stream_controller.is_none()
&& !cell.display_lines(u16::MAX).is_empty()
{
// Only break exec grouping if the cell renders visible lines.
self.flush_active_cell();
self.needs_final_message_separator = true;
@@ -11378,17 +11403,49 @@ impl ChatWidget {
self.last_rendered_width.set(Some(width as usize));
let stream_width = self.current_stream_width(/*reserved_cols*/ 2);
let plan_stream_width = self.current_stream_width(/*reserved_cols*/ 4);
let had_agent_stream = self.stream_controller.is_some();
let had_plan_stream = self.plan_stream_controller.is_some();
if let Some(controller) = self.stream_controller.as_mut() {
controller.set_width(stream_width);
}
if had_agent_stream {
self.sync_agent_stream_active_tail();
}
if let Some(controller) = self.plan_stream_controller.as_mut() {
controller.set_width(plan_stream_width);
}
if !had_rendered_width {
if had_plan_stream {
self.sync_plan_stream_active_tail();
}
if !had_rendered_width || had_agent_stream || had_plan_stream {
self.request_redraw();
}
}
fn sync_agent_stream_active_tail(&mut self) {
let Some(controller) = self.stream_controller.as_ref() else {
return;
};
let active_tail = controller.active_tail_cell();
if active_tail.is_some() {
self.bottom_pane.hide_status_indicator();
}
self.active_cell = active_tail;
self.bump_active_cell_revision();
}
fn sync_plan_stream_active_tail(&mut self) {
let Some(controller) = self.plan_stream_controller.as_ref() else {
return;
};
let active_tail = controller.active_tail_cell();
if active_tail.is_some() {
self.bottom_pane.hide_status_indicator();
}
self.active_cell = active_tail;
self.bump_active_cell_revision();
}
/// Whether an agent message stream is active (not a plan stream).
pub(crate) fn has_active_agent_stream(&self) -> bool {
self.stream_controller.is_some()

View File

@@ -452,6 +452,62 @@ where
Ok(())
}
/// Insert fully-rendered rows above the inline viewport without using scroll regions.
pub(crate) fn insert_buffer_before_viewport_without_scroll_region(
&mut self,
buffer: Buffer,
) -> io::Result<()> {
let height = buffer.area.height;
if height == 0 {
return Ok(());
}
let screen_height = self.size()?.height;
if screen_height == 0 {
return Ok(());
}
if self.viewport_area.bottom().saturating_add(height) <= screen_height {
self.draw_buffer_lines(
self.viewport_area.top(),
height,
buffer.area.width,
buffer.content.as_slice(),
)?;
self.set_viewport_area(Rect {
y: self.viewport_area.y.saturating_add(height),
..self.viewport_area
});
self.clear()?;
return Ok(());
}
let mut cells = buffer.content.as_slice();
let bottom = screen_height.saturating_sub(1);
// VTE records scrollback when a row scrolls offscreen, not when that row is later
// repainted by absolute cursor movement. Stream each rendered row through the bottom
// line so the terminal saves the row's actual cells into history.
while !cells.is_empty() {
cells =
self.draw_buffer_lines(bottom, /*lines_to_draw*/ 1, buffer.area.width, cells)?;
self.scroll_up_with_append_lines(/*lines_to_scroll*/ 1)?;
}
let viewport_height = self.viewport_area.height.min(screen_height);
for _ in 1..viewport_height {
self.scroll_up_with_append_lines(/*lines_to_scroll*/ 1)?;
}
self.set_viewport_area(Rect {
y: screen_height.saturating_sub(viewport_height),
height: viewport_height,
..self.viewport_area
});
self.clear()?;
Ok(())
}
/// Clear the entire visible screen (not just the viewport) and force a full redraw.
pub fn clear_visible_screen(&mut self) -> io::Result<()> {
let home = Position { x: 0, y: 0 };
@@ -507,6 +563,37 @@ where
pub fn size(&self) -> io::Result<Size> {
self.backend.size()
}
fn draw_buffer_lines<'a>(
&mut self,
y_offset: u16,
lines_to_draw: u16,
width: u16,
cells: &'a [Cell],
) -> io::Result<&'a [Cell]> {
let width = usize::from(width);
let (to_draw, remainder) = cells.split_at(width * lines_to_draw as usize);
if lines_to_draw > 0 {
let iter = to_draw
.iter()
.enumerate()
.map(|(i, cell)| ((i % width) as u16, y_offset + (i / width) as u16, cell));
self.backend.draw(iter)?;
Backend::flush(&mut self.backend)?;
}
Ok(remainder)
}
fn scroll_up_with_append_lines(&mut self, lines_to_scroll: u16) -> io::Result<()> {
if lines_to_scroll > 0 {
self.backend.set_cursor_position(Position::new(
/*x*/ 0,
/*y*/ self.size()?.height.saturating_sub(1),
))?;
self.backend.append_lines(lines_to_scroll)?;
}
Ok(())
}
}
use ratatui::buffer::Cell;

View File

@@ -458,6 +458,16 @@ impl HistoryCell for ReasoningSummaryCell {
pub(crate) struct AgentMessageCell {
lines: Vec<Line<'static>>,
is_first_line: bool,
/// Stable markdown source that had fully drained by the time this transient stream cell was
/// emitted. Resize reflow uses the latest snapshot in a trailing stream run to rebuild tables
/// from source instead of wrapping already-rendered table borders as plain text.
markdown_source: Option<AgentMessageMarkdownSource>,
}
#[derive(Debug)]
struct AgentMessageMarkdownSource {
source: String,
cwd: PathBuf,
}
impl AgentMessageCell {
@@ -465,8 +475,35 @@ impl AgentMessageCell {
Self {
lines,
is_first_line,
markdown_source: None,
}
}
pub(crate) fn new_with_markdown_source(
lines: Vec<Line<'static>>,
is_first_line: bool,
source: String,
cwd: &Path,
) -> Self {
Self {
lines,
is_first_line,
markdown_source: Some(AgentMessageMarkdownSource {
source,
cwd: cwd.to_path_buf(),
}),
}
}
pub(crate) fn markdown_source(&self) -> Option<(&str, &Path)> {
self.markdown_source
.as_ref()
.map(|source| (source.source.as_str(), source.cwd.as_path()))
}
pub(crate) fn is_first_line(&self) -> bool {
self.is_first_line
}
}
impl HistoryCell for AgentMessageCell {
@@ -2583,6 +2620,25 @@ pub(crate) fn new_proposed_plan_stream(
ProposedPlanStreamCell {
lines,
is_stream_continuation,
markdown_source: None,
}
}
pub(crate) fn new_proposed_plan_stream_with_markdown_source(
lines: Vec<Line<'static>>,
is_stream_continuation: bool,
source: String,
cwd: &Path,
include_bottom_padding: bool,
) -> ProposedPlanStreamCell {
ProposedPlanStreamCell {
lines,
is_stream_continuation,
markdown_source: Some(ProposedPlanStreamMarkdownSource {
source,
cwd: cwd.to_path_buf(),
include_bottom_padding,
}),
}
}
@@ -2606,6 +2662,26 @@ pub(crate) struct ProposedPlanCell {
pub(crate) struct ProposedPlanStreamCell {
lines: Vec<Line<'static>>,
is_stream_continuation: bool,
markdown_source: Option<ProposedPlanStreamMarkdownSource>,
}
#[derive(Debug)]
pub(crate) struct ProposedPlanStreamMarkdownSource {
source: String,
cwd: PathBuf,
include_bottom_padding: bool,
}
impl ProposedPlanStreamCell {
pub(crate) fn markdown_source(&self) -> Option<(&str, &Path, bool)> {
self.markdown_source.as_ref().map(|source| {
(
source.source.as_str(),
source.cwd.as_path(),
source.include_bottom_padding,
)
})
}
}
impl HistoryCell for ProposedPlanCell {

View File

@@ -28,29 +28,34 @@ use crossterm::style::SetColors;
use crossterm::style::SetForegroundColor;
use crossterm::terminal::Clear;
use crossterm::terminal::ClearType;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use ratatui::prelude::Backend;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
/// Selects the terminal escape strategy for inserting history lines above the viewport.
///
/// Standard terminals support `DECSTBM` scroll regions and Reverse Index (`ESC M`),
/// which let us slide existing content down without redrawing it. Zellij silently
/// drops or mishandles those sequences, so `Zellij` mode falls back to emitting
/// newlines at the bottom of the screen and writing lines at absolute positions.
/// which let us slide existing content down without redrawing it. Some terminals
/// or terminal-like surfaces mishandle those sequences for normal scrollback, so
/// `Newline` mode falls back to emitting newlines at the bottom of the screen
/// and writing lines at absolute positions.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InsertHistoryMode {
Standard,
Zellij,
Newline,
}
impl InsertHistoryMode {
pub fn new(is_zellij: bool) -> Self {
if is_zellij {
Self::Zellij
pub fn new(use_newline_insert: bool) -> Self {
if use_newline_insert {
Self::Newline
} else {
Self::Standard
}
@@ -72,12 +77,11 @@ where
/// Insert `lines` above the viewport, using the escape strategy selected by `mode`.
///
/// In `Standard` mode this manipulates DECSTBM scroll regions to slide existing
/// scrollback down and writes new lines into the freed space. In `Zellij` mode it
/// emits newlines at the screen bottom to create space (since Zellij ignores scroll
/// region escapes) and writes lines at computed absolute positions. Both modes
/// update `terminal.viewport_area` so subsequent draw passes know where the
/// viewport moved to. Resize reflow uses the same viewport-aware path after
/// clearing old scrollback.
/// scrollback down and writes new lines into the freed space. In `Newline` mode
/// it renders the inserted history into a dense buffer and appends full-screen
/// lines to create real scrollback. Both modes update `terminal.viewport_area`
/// so subsequent draw passes know where the viewport moved to. Resize reflow
/// uses the same buffer renderer after clearing old scrollback.
pub fn insert_history_lines_with_mode<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line>,
@@ -91,71 +95,24 @@ where
let mut area = terminal.viewport_area;
let mut should_update_area = false;
let last_cursor_pos = terminal.last_known_cursor_pos;
let writer = terminal.backend_mut();
// Pre-wrap lines for terminal scrollback. Three paths:
//
// - URL-only-ish lines are kept intact (no hard newlines inserted) so that
// terminal emulators can match them as clickable links. The
// terminal will character-wrap these lines at the viewport
// boundary.
// - Mixed lines (URL + non-URL prose) are adaptively wrapped so
// non-URL text still wraps naturally while URL tokens remain
// unsplit.
// - Non-URL lines also flow through adaptive wrapping; behavior is
// equivalent to standard wrapping when no URL is present.
let wrap_width = area.width.max(1) as usize;
let mut wrapped = Vec::new();
let mut wrapped_rows = 0usize;
for line in &lines {
let line_wrapped =
if line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line) {
vec![line.clone()]
} else {
adaptive_wrap_line(line, RtOptions::new(wrap_width))
};
wrapped_rows += line_wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
.sum::<usize>();
wrapped.extend(line_wrapped);
}
let wrapped_lines = wrapped_rows as u16;
let (wrapped, wrapped_lines) = wrap_history_lines(&lines, wrap_width);
match mode {
InsertHistoryMode::Zellij => {
let space_below = screen_size.height.saturating_sub(area.bottom());
let shift_down = wrapped_lines.min(space_below);
let scroll_up_amount = wrapped_lines.saturating_sub(shift_down);
if scroll_up_amount > 0 {
// Scroll the entire screen up by emitting \n at the bottom
queue!(
writer,
MoveTo(/*x*/ 0, screen_size.height.saturating_sub(1))
)?;
for _ in 0..scroll_up_amount {
queue!(writer, Print("\n"))?;
}
}
if shift_down > 0 {
area.y += shift_down;
should_update_area = true;
}
let cursor_top = area.top().saturating_sub(scroll_up_amount + shift_down);
queue!(writer, MoveTo(/*x*/ 0, cursor_top))?;
for (i, line) in wrapped.iter().enumerate() {
if i > 0 {
queue!(writer, Print("\r\n"))?;
}
write_history_line(writer, line, wrap_width)?;
InsertHistoryMode::Newline => {
let history_buffer = render_history_buffer(&wrapped, area.width);
let history_rows = history_buffer.area.height;
if history_rows > 0 {
terminal.insert_buffer_before_viewport_without_scroll_region(history_buffer)?;
terminal
.backend_mut()
.set_cursor_position(last_cursor_pos)?;
terminal.note_history_rows_inserted(history_rows);
}
}
InsertHistoryMode::Standard => {
let writer = terminal.backend_mut();
let cursor_top = if area.bottom() < screen_size.height {
let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom());
@@ -203,23 +160,174 @@ where
}
queue!(writer, ResetScrollRegion)?;
// Restore the cursor position to where it was before we started.
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
let _ = writer;
if should_update_area {
terminal.set_viewport_area(area);
}
if wrapped_lines > 0 {
terminal.note_history_rows_inserted(wrapped_lines);
}
}
}
// Restore the cursor position to where it was before we started.
queue!(writer, MoveTo(last_cursor_pos.x, last_cursor_pos.y))?;
Ok(())
}
let _ = writer;
if should_update_area {
terminal.set_viewport_area(area);
}
if wrapped_lines > 0 {
terminal.note_history_rows_inserted(wrapped_lines);
/// Replay history rows after the caller has cleared terminal scrollback/screen.
///
/// Unlike [`insert_history_lines_with_mode`], this does not use scroll regions or
/// reverse-index insertion. Resize reflow already owns replacing the visible
/// transcript from source-backed cells, so this path writes the rebuilt rows
/// from the top of the terminal and leaves the inline viewport directly after
/// the visible history tail.
pub(crate) fn replay_history_lines_after_clear<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line<'static>>,
) -> io::Result<()>
where
B: Backend + Write,
{
let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0));
let mut area = terminal.viewport_area;
area.width = screen_size.width;
area.y = 0;
terminal.set_viewport_area(area);
let wrap_width = area.width.max(1) as usize;
let (wrapped, _) = wrap_history_lines(&lines, wrap_width);
let history_buffer = render_history_buffer(&wrapped, area.width);
let rendered_rows = history_buffer.area.height;
if rendered_rows == 0 {
return Ok(());
}
terminal.insert_buffer_before_viewport_without_scroll_region(history_buffer)?;
terminal.note_history_rows_inserted(rendered_rows);
Ok(())
}
fn wrap_history_lines<'a>(lines: &'a [Line<'a>], wrap_width: usize) -> (Vec<Line<'a>>, u16) {
let mut wrapped = Vec::new();
let mut wrapped_rows = 0usize;
for line in lines {
let should_keep_line_intact = is_preformatted_box_table_line(line)
|| (line_contains_url_like(line) && !line_has_mixed_url_and_non_url_tokens(line));
let line_wrapped = if should_keep_line_intact {
vec![line.clone()]
} else {
adaptive_wrap_line(line, RtOptions::new(wrap_width))
};
wrapped_rows += line_wrapped
.iter()
.map(|wrapped_line| wrapped_line.width().max(1).div_ceil(wrap_width))
.sum::<usize>();
wrapped.extend(line_wrapped);
}
(wrapped, wrapped_rows as u16)
}
fn render_history_buffer(lines: &[Line<'_>], width: u16) -> Buffer {
let width = width.max(1);
let rows = lines
.iter()
.map(|line| rendered_history_line_rows(line, width))
.sum();
let mut buffer = Buffer::empty(Rect::new(
/*x*/ 0, /*y*/ 0, width, /*height*/ rows,
));
let mut y = 0;
for line in lines {
y = render_history_line_to_buffer(&mut buffer, line, width, y);
}
buffer
}
fn rendered_history_line_rows(line: &Line<'_>, width: u16) -> u16 {
let mut rows = 1u16;
let mut x = 0u16;
for span in &line.spans {
for symbol in UnicodeSegmentation::graphemes(span.content.as_ref(), true) {
if symbol.contains(char::is_control) {
continue;
}
let symbol_width = UnicodeWidthStr::width(symbol) as u16;
if symbol_width == 0 {
continue;
}
if x > 0 && x.saturating_add(symbol_width) > width {
rows = rows.saturating_add(1);
x = 0;
}
if symbol_width <= width {
x = x.saturating_add(symbol_width);
}
}
}
rows
}
fn render_history_line_to_buffer(
buffer: &mut Buffer,
line: &Line<'_>,
width: u16,
mut y: u16,
) -> u16 {
let mut x = 0u16;
for span in &line.spans {
let style = line.style.patch(span.style);
for symbol in UnicodeSegmentation::graphemes(span.content.as_ref(), true) {
if symbol.contains(char::is_control) {
continue;
}
let symbol_width = UnicodeWidthStr::width(symbol) as u16;
if symbol_width == 0 {
continue;
}
if x > 0 && x.saturating_add(symbol_width) > width {
y = y.saturating_add(1);
x = 0;
}
if symbol_width > width {
continue;
}
if let Some(cell) = buffer.cell_mut((x, y)) {
cell.set_symbol(symbol).set_style(style);
}
let next_x = x.saturating_add(symbol_width);
x = x.saturating_add(1);
while x < next_x && x < width {
if let Some(cell) = buffer.cell_mut((x, y)) {
cell.reset();
}
x = x.saturating_add(1);
}
}
}
y.saturating_add(1)
}
fn is_preformatted_box_table_line(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
let trimmed = text.trim_start();
(trimmed.starts_with('│') && trimmed.matches('│').count() >= 2)
|| trimmed.starts_with('┌')
|| trimmed.starts_with('├')
|| trimmed.starts_with('└')
}
/// Render a single wrapped history line: clear continuation rows for wide lines,
/// set foreground/background colors, and write styled spans. Caller is responsible
/// for cursor positioning and any leading `\r\n`.
@@ -413,9 +521,46 @@ mod tests {
use super::*;
use crate::markdown_render::render_markdown_text;
use crate::test_backend::VT100Backend;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
fn buffer_rows(buffer: &Buffer) -> Vec<String> {
buffer
.content
.chunks(buffer.area.width as usize)
.map(|row| row.iter().map(ratatui::buffer::Cell::symbol).collect())
.collect()
}
#[test]
fn detects_preformatted_box_table_lines() {
assert!(is_preformatted_box_table_line(&Line::from(
"│ Link │ a (https://example.com/a) │"
)));
assert!(is_preformatted_box_table_line(&Line::from(
"┌──────┬────────┐"
)));
assert!(!is_preformatted_box_table_line(&Line::from(
"plain text with https://example.com"
)));
assert!(!is_preformatted_box_table_line(&Line::from(
" │ quoted text with https://example.com"
)));
}
#[test]
fn history_buffer_splits_preserved_box_rows_by_physical_width() {
let line = Line::from("│ abcdefghij │");
let buffer = render_history_buffer(&[line], /*width*/ 8);
let rows = buffer_rows(&buffer);
assert_eq!(buffer.area.height, 2);
assert_eq!(rows[0].trim_end(), "│ abcdef");
assert_eq!(rows[1].trim_end(), "ghij │");
}
#[test]
fn writes_bold_then_regular_spans() {
use ratatui::style::Stylize;
@@ -814,7 +959,7 @@ mod tests {
}
#[test]
fn vt100_zellij_mode_inserts_history_and_updates_viewport() {
fn vt100_newline_mode_inserts_history_and_updates_viewport() {
let width: u16 = 32;
let height: u16 = 8;
let backend = VT100Backend::new(width, height);
@@ -823,7 +968,7 @@ mod tests {
term.set_viewport_area(viewport);
let line: Line<'static> = Line::from("zellij history");
insert_history_lines_with_mode(&mut term, vec![line], InsertHistoryMode::Zellij)
insert_history_lines_with_mode(&mut term, vec![line], InsertHistoryMode::Newline)
.expect("insert zellij history");
let start_row = 0;
@@ -840,4 +985,216 @@ mod tests {
assert_eq!(term.viewport_area, Rect::new(0, 5, width, 2));
assert_eq!(term.visible_history_rows(), 1);
}
#[test]
fn vt100_newline_mode_keeps_large_insert_tail_above_viewport() {
let width: u16 = 48;
let height: u16 = 12;
let viewport_height: u16 = 2;
let backend = VT100Backend::new_with_scrollback(width, height, /*scrollback_len*/ 128);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let viewport = Rect::new(
/*x*/ 0,
height - viewport_height,
width,
viewport_height,
);
term.set_viewport_area(viewport);
let lines = (1..=18)
.map(|index| Line::from(format!("history row {index:02}")))
.collect();
insert_history_lines_with_mode(&mut term, lines, InsertHistoryMode::Newline)
.expect("insert large newline-mode history");
// A normal draw immediately repaints the inline viewport after history insertion.
// The inserted history tail must remain above that viewport rather than being
// written into rows that the draw will clear.
let viewport = term.viewport_area;
{
let writer = term.backend_mut();
for y in viewport.top()..viewport.bottom() {
queue!(writer, MoveTo(/*x*/ 0, y), Clear(ClearType::UntilNewLine))
.expect("clear viewport row");
}
}
let rows: Vec<String> = term
.backend()
.vt100()
.screen()
.rows(/*start*/ 0, width)
.collect();
let tail = rows[..viewport.top() as usize].join("\n");
assert!(
tail.contains("history row 18"),
"expected final inserted row above viewport, rows={rows:?}, viewport={viewport:?}",
);
term.backend_mut()
.vt100_mut()
.screen_mut()
.set_scrollback(usize::MAX);
let scrolled_rows: Vec<String> = term
.backend()
.vt100()
.screen()
.rows(/*start*/ 0, width)
.collect();
let scrolled = scrolled_rows.join("\n");
assert!(
scrolled.contains("history row 01"),
"expected oldest inserted rows in terminal scrollback, rows={scrolled_rows:?}",
);
}
#[test]
fn vt100_newline_mode_persists_rendered_markdown_table_in_scrollback() {
let width: u16 = 72;
let height: u16 = 10;
let viewport_height: u16 = 2;
let backend = VT100Backend::new_with_scrollback(width, height, /*scrollback_len*/ 256);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(
/*x*/ 0,
height - viewport_height,
width,
viewport_height,
));
let markdown = "\
| Col A | Col B | Col C | Col D | Col E |\n\
| --- | --- | --- | --- | --- |\n\
| a1 | `cargo test` | 😀 | [docs](https://example.com/a) | ok |\n\
| a2 | `cargo fmt` | 🚧 | [guide](https://example.com/b) | maybe |\n\
| a3 | `assert!()` | 🧪 | [api](https://example.com/c) | test |\n\
| a4 | `grep` | 🔍 | [search](https://example.com/d) | search |\n\
| a5 | `zip` | 🎁 | [bundle](https://example.com/e) | bundle |\n";
let mut lines = vec![Line::from("before table marker")];
lines.extend(render_markdown_text(markdown).lines);
insert_history_lines_with_mode(&mut term, lines, InsertHistoryMode::Newline)
.expect("insert rendered table history");
let viewport = term.viewport_area;
{
let writer = term.backend_mut();
for y in viewport.top()..viewport.bottom() {
queue!(writer, MoveTo(/*x*/ 0, y), Clear(ClearType::UntilNewLine))
.expect("clear viewport row");
}
}
let visible_rows: Vec<String> = term
.backend()
.vt100()
.screen()
.rows(/*start*/ 0, width)
.collect();
let visible_tail = visible_rows[..viewport.top() as usize].join("\n");
assert!(
visible_tail.contains("zip") && visible_tail.contains("bundle"),
"expected final table row above viewport after repaint: {visible_rows:?}",
);
let max_scrollback = {
let screen = term.backend_mut().vt100_mut().screen_mut();
screen.set_scrollback(usize::MAX);
screen.scrollback()
};
let mut scrollback_windows = Vec::new();
for offset in (0..=max_scrollback).rev() {
term.backend_mut()
.vt100_mut()
.screen_mut()
.set_scrollback(offset);
let rows: Vec<String> = term
.backend()
.vt100()
.screen()
.rows(/*start*/ 0, width)
.collect();
scrollback_windows.push(rows);
}
let scrollback = scrollback_windows
.iter()
.flatten()
.cloned()
.collect::<Vec<_>>()
.join("\n");
assert!(
scrollback.contains("before table marker"),
"expected marker row in scrollback windows: {scrollback_windows:?}",
);
assert!(
scrollback.contains('┌') && scrollback.contains('┐'),
"expected table border row in scrollback windows: {scrollback_windows:?}",
);
assert!(
scrollback.contains("zip") && scrollback.contains("bundle"),
"expected final table row in scrollback windows: {scrollback_windows:?}",
);
}
#[test]
fn vt100_resize_replay_replaces_cleared_history_without_incremental_insert() {
let width: u16 = 36;
let height: u16 = 14;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 0, width, /*height*/ 3,
));
insert_history_lines(&mut term, vec![Line::from("stale row that must disappear")])
.expect("insert stale history");
term.clear_scrollback_and_visible_screen_ansi()
.expect("clear before replay");
let lines = vec![
Line::from("┌─────────┬────────────────────┐"),
Line::from("│ Label │ Alpha │"),
Line::from("│ Content │ first value │"),
Line::from("├─────────┼────────────────────┤"),
Line::from("│ Label │ Beta │"),
Line::from("│ Content │ second value │"),
Line::from("└─────────┴────────────────────┘"),
];
replay_history_lines_after_clear(&mut term, lines).expect("replay history");
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
assert_eq!(term.viewport_area, Rect::new(0, 7, width, 3));
assert_eq!(term.visible_history_rows(), 7);
assert!(rows[0].contains("┌─────────┬────────────────────┐"));
assert!(rows[6].contains("└─────────┴────────────────────┘"));
assert!(
!rows.iter().any(|row| row.contains("stale row")),
"stale history survived resize replay: {rows:?}"
);
}
#[test]
fn vt100_resize_replay_keeps_visible_tail_above_viewport_when_history_overflows() {
let width: u16 = 20;
let height: u16 = 8;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(Rect::new(
/*x*/ 0, /*y*/ 0, width, /*height*/ 2,
));
let lines = (1..=10)
.map(|index| Line::from(format!("history row {index:02}")))
.collect();
replay_history_lines_after_clear(&mut term, lines).expect("replay overflowing history");
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();
assert_eq!(term.viewport_area, Rect::new(0, 6, width, 2));
assert_eq!(term.visible_history_rows(), 6);
assert!(rows[0].contains("history row 05"), "rows: {rows:?}");
assert!(rows[5].contains("history row 10"), "rows: {rows:?}");
assert!(rows[6].trim().is_empty(), "rows: {rows:?}");
assert!(rows[7].trim().is_empty(), "rows: {rows:?}");
}
}

View File

@@ -27,8 +27,16 @@ use regex_lite::Regex;
use std::path::Path;
use std::path::PathBuf;
use std::sync::LazyLock;
use unicode_width::UnicodeWidthStr;
use url::Url;
mod table;
mod table_cell;
mod table_state;
use table::normalize_table_boundaries;
use table::render_table_lines;
use table_state::TableState;
struct MarkdownStyles {
h1: Style,
h2: Style,
@@ -108,7 +116,9 @@ pub(crate) fn render_markdown_text_with_width_and_cwd(
) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
options.insert(Options::ENABLE_TABLES);
let normalized_input = normalize_table_boundaries(input);
let parser = Parser::new_ext(normalized_input.as_ref(), options);
let mut w = Writer::new(parser, width, cwd);
w.run();
w.text
@@ -172,6 +182,7 @@ where
current_subsequent_indent: Vec<Span<'static>>,
current_line_style: Style,
current_line_in_code_block: bool,
table: Option<TableState>,
}
impl<'a, I> Writer<'a, I>
@@ -204,6 +215,7 @@ where
current_subsequent_indent: Vec::new(),
current_line_style: Style::default(),
current_line_in_code_block: false,
table: None,
}
}
@@ -277,12 +289,11 @@ where
Tag::Strong => self.push_inline_style(self.styles.strong),
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
Tag::Table(_) => self.start_table(),
Tag::TableHead | Tag::TableRow => self.start_table_row(),
Tag::TableCell => self.start_table_cell(),
Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
| Tag::TableHead
| Tag::TableRow
| Tag::TableCell
| Tag::Image { .. }
| Tag::MetadataBlock(_) => {}
}
@@ -305,13 +316,18 @@ where
self.pending_marker_line = false;
}
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Strikethrough => self.pop_inline_style(),
TagEnd::Link => self.pop_link(),
TagEnd::Link => {
if self.table.is_some() {
self.pop_table_link();
} else {
self.pop_link();
}
}
TagEnd::Table => self.end_table(),
TagEnd::TableHead | TagEnd::TableRow => self.end_table_row(),
TagEnd::TableCell => self.end_table_cell(),
TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
| TagEnd::Table
| TagEnd::TableHead
| TagEnd::TableRow
| TagEnd::TableCell
| TagEnd::Image
| TagEnd::MetadataBlock(_) => {}
}
@@ -373,7 +389,69 @@ where
self.needs_newline = true;
}
fn start_table(&mut self) {
if self.needs_newline {
self.push_blank_line();
self.needs_newline = false;
}
self.flush_current_line();
self.table = Some(TableState::default());
}
fn end_table(&mut self) {
let Some(table) = self.table.take() else {
return;
};
let prefix_width = self
.prefix_spans(/*pending_marker_line*/ false)
.iter()
.map(|span| span.content.width())
.sum();
let table_width = self
.wrap_width
.map(|width| width.saturating_sub(prefix_width));
for line in render_table_lines(&table.rows, table_width) {
self.push_line(line);
self.flush_current_line();
}
self.needs_newline = true;
}
fn start_table_row(&mut self) {
if let Some(table) = self.table.as_mut() {
table.start_row();
}
}
fn end_table_row(&mut self) {
if let Some(table) = self.table.as_mut() {
table.end_row();
}
}
fn start_table_cell(&mut self) {
if let Some(table) = self.table.as_mut() {
table.start_cell();
}
}
fn end_table_cell(&mut self) {
if let Some(table) = self.table.as_mut() {
table.end_cell();
}
}
fn text(&mut self, text: CowStr<'a>) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(&text, style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -427,6 +505,15 @@ where
}
fn code(&mut self, code: CowStr<'a>) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
if let Some(table) = self.table.as_mut() {
table.push_text(&code, self.styles.code);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -440,6 +527,16 @@ where
}
fn html(&mut self, html: CowStr<'a>, inline: bool) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_html(&html, style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -460,6 +557,16 @@ where
}
fn hard_break(&mut self) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(" ", style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -468,6 +575,16 @@ where
}
fn soft_break(&mut self) {
if self.table.is_some() {
if self.suppressing_local_link_label() {
return;
}
let style = self.inline_styles.last().copied().unwrap_or_default();
if let Some(table) = self.table.as_mut() {
table.push_text(" ", style);
}
return;
}
if self.suppressing_local_link_label() {
return;
}
@@ -640,6 +757,31 @@ where
}
}
fn pop_table_link(&mut self) {
let Some(link) = self.link.take() else {
return;
};
let Some(table) = self.table.as_mut() else {
return;
};
if link.show_destination {
table.push_span(" (".into());
table.push_span(Span::styled(link.destination, self.styles.link));
table.push_span(")".into());
} else if let Some(local_target_display) = link.local_target_display {
// Local file links are rendered as code-like path text so the transcript shows the
// resolved target instead of arbitrary caller-provided label text.
let style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.code);
table.push_span(Span::styled(local_target_display, style));
}
}
fn suppressing_local_link_label(&self) -> bool {
self.link
.as_ref()

View File

@@ -0,0 +1,947 @@
use super::table_cell::TableCell;
use std::borrow::Cow;
use std::ops::Range;
use crate::render::line_utils::line_to_static;
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthStr;
#[derive(Debug)]
struct TableLayoutCandidate {
column_widths: Vec<usize>,
padding: usize,
hard_wrap: bool,
}
#[derive(Debug)]
struct TableMetrics {
average_body_row_height: f64,
max_body_row_height: usize,
hard_wrap_count: usize,
}
pub(super) fn render_table_lines(
rows: &[Vec<TableCell>],
width: Option<usize>,
) -> Vec<Line<'static>> {
if rows.is_empty() {
return Vec::new();
}
let column_count = rows.iter().map(Vec::len).max().unwrap_or(0);
if column_count == 0 {
return Vec::new();
}
let normalized_rows = normalize_table_rows(rows, column_count);
let terminal_width = width.unwrap_or(usize::MAX / 4);
let safety_columns = if has_width_risk_chars(&normalized_rows) {
2
} else {
1
};
let available_width = terminal_width.saturating_sub(safety_columns).max(1);
let widths = desired_column_widths(&normalized_rows, column_count);
match choose_table_layout(&normalized_rows, &widths, available_width, column_count) {
Some(candidate) => render_box_table(
&normalized_rows,
&candidate.column_widths,
candidate.padding,
candidate.hard_wrap,
),
None => render_vertical_table(&normalized_rows, available_width),
}
}
pub(super) fn normalize_table_boundaries(input: &str) -> Cow<'_, str> {
if !input.contains('|') {
return Cow::Borrowed(input);
}
let lines = input.split_inclusive('\n').collect::<Vec<_>>();
let mut out = String::with_capacity(input.len());
let mut changed = false;
let mut index = 0;
let mut code_fence: Option<(char, usize)> = None;
while index < lines.len() {
if let Some(fence) = code_fence {
out.push_str(lines[index]);
if is_closing_code_fence(lines[index], fence) {
code_fence = None;
}
index += 1;
} else if let Some(fence) = opening_code_fence(lines[index]) {
code_fence = Some(fence);
out.push_str(lines[index]);
index += 1;
} else if is_indented_code_line(lines[index]) {
out.push_str(lines[index]);
index += 1;
} else if index + 1 < lines.len()
&& is_table_row_source(lines[index])
&& is_table_delimiter_source(lines[index + 1])
{
push_table_row_source(&mut out, lines[index], &mut changed);
out.push_str(lines[index + 1]);
index += 2;
while index < lines.len() && is_table_row_source(lines[index]) {
push_table_row_source(&mut out, lines[index], &mut changed);
index += 1;
}
if index < lines.len() && !lines[index].trim().is_empty() {
out.push('\n');
changed = true;
}
} else {
out.push_str(lines[index]);
index += 1;
}
}
if changed {
Cow::Owned(out)
} else {
Cow::Borrowed(input)
}
}
fn opening_code_fence(line: &str) -> Option<(char, usize)> {
let trimmed = strip_fence_indent(line)?;
let mut chars = trimmed.chars();
let marker = chars.next()?;
if marker != '`' && marker != '~' {
return None;
}
let marker_count = 1 + chars.take_while(|ch| *ch == marker).count();
(marker_count >= 3).then_some((marker, marker_count))
}
fn is_closing_code_fence(line: &str, (marker, opening_count): (char, usize)) -> bool {
let Some(trimmed) = strip_fence_indent(line) else {
return false;
};
let marker_count = trimmed.chars().take_while(|ch| *ch == marker).count();
marker_count >= opening_count
&& trimmed[marker.len_utf8() * marker_count..]
.trim()
.is_empty()
}
fn strip_fence_indent(line: &str) -> Option<&str> {
let mut spaces = 0usize;
for (index, ch) in line.char_indices() {
if ch != ' ' {
return (spaces <= 3).then_some(&line[index..]);
}
spaces += 1;
if spaces > 3 {
return None;
}
}
Some("")
}
fn is_indented_code_line(line: &str) -> bool {
line.starts_with(" ") || line.starts_with('\t')
}
fn is_table_row_source(line: &str) -> bool {
let trimmed = line.trim();
!trimmed.is_empty() && table_cell_sources(trimmed).len() > 1
}
fn is_table_delimiter_source(line: &str) -> bool {
let mut cells = table_cell_sources(line.trim());
if cells.first().is_some_and(|cell| cell.trim().is_empty()) {
cells.remove(0);
}
if cells.last().is_some_and(|cell| cell.trim().is_empty()) {
cells.pop();
}
if cells.is_empty() {
return false;
}
cells.iter().all(|cell| {
let cell = cell.trim();
let dash_count = cell.chars().filter(|ch| *ch == '-').count();
dash_count >= 3 && cell.chars().all(|ch| matches!(ch, '-' | ':' | ' '))
})
}
fn push_table_row_source(out: &mut String, line: &str, changed: &mut bool) {
match escape_inline_code_pipes_in_table_row(line) {
Cow::Borrowed(line) => out.push_str(line),
Cow::Owned(line) => {
*changed = true;
out.push_str(&line);
}
}
}
fn escape_inline_code_pipes_in_table_row(line: &str) -> Cow<'_, str> {
if !line.contains('`') {
return Cow::Borrowed(line);
}
let code_ranges = inline_code_span_ranges(line);
if code_ranges.is_empty() {
return Cow::Borrowed(line);
}
let mut out: Option<String> = None;
let mut last = 0;
for (index, ch) in line.char_indices() {
if ch == '|'
&& is_index_in_ranges(index, &code_ranges)
&& !is_backslash_escaped(line.as_bytes(), index)
{
let out = out.get_or_insert_with(|| String::with_capacity(line.len() + 1));
out.push_str(&line[last..index]);
out.push('\\');
last = index;
}
}
match out {
Some(mut out) => {
out.push_str(&line[last..]);
Cow::Owned(out)
}
None => Cow::Borrowed(line),
}
}
fn table_cell_sources(line: &str) -> Vec<&str> {
let code_ranges = inline_code_span_ranges(line);
let mut cells = Vec::new();
let mut cell_start = 0;
for (index, ch) in line.char_indices() {
if ch == '|'
&& !is_index_in_ranges(index, &code_ranges)
&& !is_backslash_escaped(line.as_bytes(), index)
{
cells.push(&line[cell_start..index]);
cell_start = index + ch.len_utf8();
}
}
cells.push(&line[cell_start..]);
cells
}
fn inline_code_span_ranges(line: &str) -> Vec<Range<usize>> {
let bytes = line.as_bytes();
let mut ranges = Vec::new();
let mut index = 0;
while index < bytes.len() {
match bytes[index] {
b'\\' => {
index = next_char_index(line, index + 1).unwrap_or(bytes.len());
}
b'`' => {
let delimiter_len = repeated_ascii_len(bytes, index, /*needle*/ b'`');
if let Some(closing_start) =
find_closing_backtick_run(line, index + delimiter_len, delimiter_len)
{
let closing_end = closing_start + delimiter_len;
ranges.push(index..closing_end);
index = closing_end;
} else {
index += delimiter_len;
}
}
_ => {
index = next_char_index(line, index).unwrap_or(bytes.len());
}
}
}
ranges
}
fn find_closing_backtick_run(line: &str, start: usize, delimiter_len: usize) -> Option<usize> {
let bytes = line.as_bytes();
let mut index = start;
while index < bytes.len() {
if bytes[index] == b'`' {
let run_len = repeated_ascii_len(bytes, index, /*needle*/ b'`');
if run_len == delimiter_len {
return Some(index);
}
index += run_len;
} else {
index = next_char_index(line, index).unwrap_or(bytes.len());
}
}
None
}
fn repeated_ascii_len(bytes: &[u8], start: usize, needle: u8) -> usize {
bytes[start..]
.iter()
.take_while(|byte| **byte == needle)
.count()
}
fn next_char_index(line: &str, index: usize) -> Option<usize> {
line.get(index..)?
.chars()
.next()
.map(|ch| index + ch.len_utf8())
}
fn is_index_in_ranges(index: usize, ranges: &[Range<usize>]) -> bool {
ranges.iter().any(|range| range.contains(&index))
}
fn is_backslash_escaped(bytes: &[u8], index: usize) -> bool {
let mut backslashes = 0;
let mut cursor = index;
while cursor > 0 && bytes[cursor - 1] == b'\\' {
backslashes += 1;
cursor -= 1;
}
backslashes % 2 == 1
}
fn normalize_table_rows(rows: &[Vec<TableCell>], column_count: usize) -> Vec<Vec<TableCell>> {
rows.iter()
.map(|row| {
let mut normalized = row.clone();
normalized.resize(column_count, TableCell::default());
normalized
})
.collect()
}
fn desired_column_widths(rows: &[Vec<TableCell>], column_count: usize) -> Vec<usize> {
let mut widths = vec![3; column_count];
for row in rows {
for (index, cell) in row.iter().enumerate() {
widths[index] = widths[index].max(cell.width());
}
}
widths
}
fn choose_table_layout(
rows: &[Vec<TableCell>],
desired_widths: &[usize],
available_width: usize,
column_count: usize,
) -> Option<TableLayoutCandidate> {
let normal = allocate_table_widths(
rows,
desired_widths,
available_width,
column_count,
/*padding*/ 1,
)
.and_then(|widths| {
build_table_candidate(rows, widths, available_width, /*padding*/ 1)
});
if normal.is_some() {
return normal;
}
allocate_table_widths(
rows,
desired_widths,
available_width,
column_count,
/*padding*/ 0,
)
.and_then(|widths| {
build_table_candidate(rows, widths, available_width, /*padding*/ 0)
})
}
fn build_table_candidate(
rows: &[Vec<TableCell>],
column_widths: Vec<usize>,
available_width: usize,
padding: usize,
) -> Option<TableLayoutCandidate> {
let metrics = table_metrics(rows, &column_widths, /*hard_wrap*/ false);
let needs_hard_wrap = metrics.hard_wrap_count > 0 || metrics.max_body_row_height > 12;
let (hard_wrap, metrics) = if needs_hard_wrap {
(
true,
table_metrics(rows, &column_widths, /*hard_wrap*/ true),
)
} else {
(false, metrics)
};
if should_render_vertical(rows, &column_widths, available_width, padding, &metrics) {
return None;
}
Some(TableLayoutCandidate {
column_widths,
padding,
hard_wrap,
})
}
fn allocate_table_widths(
rows: &[Vec<TableCell>],
desired_widths: &[usize],
available_width: usize,
column_count: usize,
padding: usize,
) -> Option<Vec<usize>> {
let border_width = column_count + 1;
let padding_width = padding * 2 * column_count;
let available_content_width = available_width.checked_sub(border_width + padding_width)?;
let min_total = 3 * column_count;
if available_content_width < min_total {
return None;
}
let mut widths = vec![3; column_count];
let mut remaining = available_content_width - min_total;
let basic_targets = desired_widths
.iter()
.enumerate()
.map(|(index, desired)| {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(normalized_header)
.unwrap_or_default();
let is_icon_column = matches!(header.as_str(), "icon" | "emoji");
let compact_target = if is_index_column(rows, index) || is_icon_column {
4
} else {
6
};
(*desired).min(compact_target).max(3)
})
.collect::<Vec<_>>();
grow_columns_to_targets(&mut widths, &mut remaining, &basic_targets);
let content_targets = desired_widths
.iter()
.enumerate()
.map(|(index, desired)| {
if is_content_heavy_column(rows, index) {
(*desired).min(24).max(widths[index])
} else {
widths[index]
}
})
.collect::<Vec<_>>();
grow_columns_to_targets(&mut widths, &mut remaining, &content_targets);
grow_columns_to_targets(&mut widths, &mut remaining, desired_widths);
Some(widths)
}
fn grow_columns_to_targets(widths: &mut [usize], remaining: &mut usize, targets: &[usize]) {
while *remaining > 0 {
let mut changed = false;
for index in 0..widths.len() {
if *remaining == 0 {
break;
}
if widths[index] < targets[index] {
widths[index] += 1;
*remaining -= 1;
changed = true;
}
}
if !changed {
break;
}
}
}
fn render_box_table(
rows: &[Vec<TableCell>],
column_widths: &[usize],
padding: usize,
hard_wrap: bool,
) -> Vec<Line<'static>> {
let mut out = Vec::new();
out.push(Line::from(border_line(
"",
"",
"",
column_widths,
padding,
)));
for (index, row) in rows.iter().enumerate() {
out.extend(render_table_row(row, column_widths, padding, hard_wrap));
if index == 0 {
out.push(Line::from(border_line(
"",
"",
"",
column_widths,
padding,
)));
}
}
out.push(Line::from(border_line(
"",
"",
"",
column_widths,
padding,
)));
out
}
fn render_table_row(
row: &[TableCell],
column_widths: &[usize],
padding: usize,
hard_wrap: bool,
) -> Vec<Line<'static>> {
let wrapped_cells = row
.iter()
.zip(column_widths)
.map(|(cell, width)| wrap_table_cell(cell, *width, hard_wrap))
.collect::<Vec<_>>();
let row_height = wrapped_cells.iter().map(Vec::len).max().unwrap_or(1);
let mut out = Vec::with_capacity(row_height);
for line_index in 0..row_height {
let mut spans = vec![Span::from("")];
for (cell_lines, width) in wrapped_cells.iter().zip(column_widths) {
let content = cell_lines.get(line_index);
push_padding(&mut spans, padding);
if let Some(content) = content {
spans.extend(content.spans.iter().cloned());
push_padding(&mut spans, width.saturating_sub(content.width()));
} else {
push_padding(&mut spans, *width);
}
push_padding(&mut spans, padding);
spans.push(Span::from(""));
}
out.push(Line::from(spans));
}
out
}
fn push_padding(spans: &mut Vec<Span<'static>>, width: usize) {
if width > 0 {
spans.push(Span::from(" ".repeat(width)));
}
}
fn border_line(
left: &str,
separator: &str,
right: &str,
column_widths: &[usize],
padding: usize,
) -> String {
let cell_segments = column_widths
.iter()
.map(|width| "".repeat(width + padding * 2))
.collect::<Vec<_>>();
format!("{left}{}{right}", cell_segments.join(separator))
}
fn wrap_table_cell(cell: &TableCell, width: usize, hard_wrap: bool) -> Vec<Line<'static>> {
if cell.lines().is_empty() {
return vec![Line::default()];
}
let mut lines = Vec::new();
let options = RtOptions::new(width)
.break_words(hard_wrap)
.word_separator(textwrap::WordSeparator::AsciiSpace)
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
for segment in cell.lines() {
if segment.width() == 0 {
lines.push(Line::default());
continue;
}
let wrapped = word_wrap_line(segment, options.clone())
.into_iter()
.map(|line| line_to_static(&line))
.collect::<Vec<_>>();
if wrapped.is_empty() {
lines.push(Line::default());
} else {
lines.extend(wrapped);
}
}
if lines.is_empty() {
vec![Line::default()]
} else {
lines
}
}
fn table_metrics(
rows: &[Vec<TableCell>],
column_widths: &[usize],
hard_wrap: bool,
) -> TableMetrics {
let mut row_heights = Vec::with_capacity(rows.len());
let mut hard_wrap_count = 0usize;
for row in rows {
let row_height = row
.iter()
.zip(column_widths)
.map(|(cell, width)| {
if cell_needs_hard_wrap(cell, *width) {
hard_wrap_count += 1;
}
wrap_table_cell(cell, *width, hard_wrap).len()
})
.max()
.unwrap_or(1);
row_heights.push(row_height);
}
let body_heights = row_heights.iter().skip(1).copied().collect::<Vec<_>>();
let body_row_count = body_heights.len();
let max_body_row_height = body_heights.iter().copied().max().unwrap_or(0);
let average_body_row_height = if body_row_count == 0 {
0.0
} else {
body_heights.iter().sum::<usize>() as f64 / body_row_count as f64
};
TableMetrics {
average_body_row_height,
max_body_row_height,
hard_wrap_count,
}
}
fn cell_needs_hard_wrap(cell: &TableCell, width: usize) -> bool {
cell.plain_text()
.split('\n')
.flat_map(str::split_whitespace)
.any(|token| token.width() > width)
}
fn should_render_vertical(
rows: &[Vec<TableCell>],
column_widths: &[usize],
available_width: usize,
padding: usize,
metrics: &TableMetrics,
) -> bool {
let column_count = column_widths.len();
let body_rows = rows.len().saturating_sub(1);
if metrics.max_body_row_height > 24
|| (body_rows >= 10 && metrics.average_body_row_height > 6.0)
|| (body_rows >= 24 && metrics.average_body_row_height > 4.0)
{
return true;
}
let non_index_columns = (0..column_count)
.filter(|index| !is_index_column(rows, *index))
.count();
let starved_non_index_columns = column_widths
.iter()
.enumerate()
.filter(|(index, width)| !is_index_column(rows, *index) && **width <= 3)
.count();
if column_count >= 4
&& body_rows >= 12
&& non_index_columns > 0
&& starved_non_index_columns == non_index_columns
{
return true;
}
has_width_risk_chars(rows)
&& available_width <= 24
&& table_total_width(column_widths, padding) >= available_width
}
fn table_total_width(column_widths: &[usize], padding: usize) -> usize {
column_widths.iter().sum::<usize>()
+ column_widths.len()
+ 1
+ padding * 2 * column_widths.len()
}
fn is_index_column(rows: &[Vec<TableCell>], index: usize) -> bool {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(normalized_header)
.unwrap_or_default();
if matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row") {
return true;
}
rows.iter()
.skip(1)
.filter_map(|row| row.get(index))
.map(TableCell::trimmed_plain_text)
.filter(|cell| !cell.is_empty())
.all(|cell| cell.chars().all(|ch| ch.is_ascii_digit()))
}
fn is_content_heavy_column(rows: &[Vec<TableCell>], index: usize) -> bool {
let header = rows
.first()
.and_then(|row| row.get(index))
.map(normalized_header)
.unwrap_or_default();
if [
"link",
"url",
"code",
"sample",
"content",
"description",
"summary",
"expectation",
"notes",
]
.iter()
.any(|needle| header.contains(needle))
{
return true;
}
rows.iter()
.skip(1)
.filter_map(|row| row.get(index))
.any(|cell| is_url_or_code_like(cell) || cell.width() > 24)
}
fn is_url_or_code_like(cell: &TableCell) -> bool {
let text = cell.plain_text();
text.contains("://")
|| text.contains("::")
|| text.contains("=>")
|| text.contains("->")
|| text.contains('`')
|| text.contains('{')
|| text.contains('}')
|| text.contains('(')
|| text.contains(')')
}
fn has_width_risk_chars(rows: &[Vec<TableCell>]) -> bool {
rows.iter().flatten().any(|cell| {
cell.plain_text().chars().any(|ch| {
matches!(ch, '\u{fe0f}' | '\u{200d}') || ('\u{1f300}'..='\u{1faff}').contains(&ch)
})
})
}
fn normalized_header(header: &TableCell) -> String {
header.trimmed_plain_text().to_ascii_lowercase()
}
fn render_vertical_table(rows: &[Vec<TableCell>], available_width: usize) -> Vec<Line<'static>> {
let Some((headers, body_rows)) = rows.split_first() else {
return Vec::new();
};
let included_columns = included_vertical_columns(headers, body_rows);
if included_columns.is_empty() {
return Vec::new();
}
let max_header_width = included_columns
.iter()
.map(|index| vertical_label(headers, *index).width())
.max()
.unwrap_or(4);
let max_value_width = body_rows
.iter()
.flat_map(|row| included_columns.iter().filter_map(|index| row.get(*index)))
.flat_map(|cell| {
cell.plain_text()
.lines()
.map(str::to_string)
.collect::<Vec<_>>()
})
.map(|line| line.width())
.max()
.unwrap_or(1);
let available_content_width = available_width.saturating_sub(7);
let (label_width, value_width) = if available_content_width < 6 {
let label_width = available_content_width.saturating_div(2).max(1);
(
label_width,
available_content_width.saturating_sub(label_width).max(1),
)
} else {
let min_label_width = 3;
let min_value_width = 3;
let desired_label_width = max_header_width.min(20).max(min_label_width);
let label_width =
desired_label_width.min(available_content_width.saturating_sub(min_value_width));
let desired_value_width = max_value_width.max(min_value_width);
let value_width = desired_value_width
.min(available_content_width.saturating_sub(label_width))
.max(min_value_width);
(label_width, value_width)
};
let mut out = Vec::new();
out.push(Line::from(border_line(
"",
"",
"",
&[label_width, value_width],
/*padding*/ 1,
)));
for (row_index, row) in body_rows.iter().enumerate() {
if row_index > 0 {
out.push(Line::from(border_line(
"",
"",
"",
&[label_width, value_width],
/*padding*/ 1,
)));
}
for index in &included_columns {
let label = truncate_to_width(&vertical_label(headers, *index), label_width);
let empty_cell = TableCell::default();
let cell = row.get(*index).unwrap_or(&empty_cell);
let wrapped = if cell.is_blank() {
vec![Line::from("")]
} else {
wrap_table_cell(cell, value_width, /*hard_wrap*/ true)
};
let wrapped = if wrapped.is_empty() {
vec![Line::default()]
} else {
wrapped
};
for (line_index, value_line) in wrapped.iter().enumerate() {
let label = if line_index == 0 { label.as_str() } else { "" };
let label_padding = label_width.saturating_sub(label.width());
let value_padding = value_width.saturating_sub(value_line.width());
let mut spans = vec![Span::from("")];
push_padding(&mut spans, label_padding);
spans.push(Span::from(label.to_string()));
spans.push(Span::from(""));
spans.extend(value_line.spans.iter().cloned());
push_padding(&mut spans, value_padding);
spans.push(Span::from(""));
out.push(Line::from(spans));
}
}
}
out.push(Line::from(border_line(
"",
"",
"",
&[label_width, value_width],
/*padding*/ 1,
)));
out
}
fn included_vertical_columns(headers: &[TableCell], body_rows: &[Vec<TableCell>]) -> Vec<usize> {
let first_column_titles_rows = headers
.first()
.map(normalized_header)
.is_some_and(|header| matches!(header.as_str(), "#" | "id" | "idx" | "index" | "row"))
&& headers.len() > 1;
(0..headers.len())
.filter(|index| !(first_column_titles_rows && *index == 0))
.filter(|index| {
!headers[*index].is_blank()
|| body_rows
.iter()
.any(|row| row.get(*index).is_some_and(|cell| !cell.is_blank()))
})
.collect()
}
fn vertical_label(headers: &[TableCell], index: usize) -> String {
headers
.get(index)
.map(TableCell::trimmed_plain_text)
.filter(|header| !header.is_empty())
.unwrap_or_else(|| "Column".to_string())
}
fn truncate_to_width(input: &str, max_width: usize) -> String {
if input.width() <= max_width {
return input.to_string();
}
if max_width == 0 {
return String::new();
}
if max_width == 1 {
return "".to_string();
}
let mut out = String::new();
let target = max_width - 1;
let mut width = 0usize;
for ch in input.chars() {
let ch_width = ch.to_string().width();
if width + ch_width > target {
break;
}
out.push(ch);
width += ch_width;
}
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn table_cell_sources_ignore_escaped_and_inline_code_pipes() {
let cells = table_cell_sources("| a | `b | c` | d \\| e |");
assert_eq!(
cells.iter().map(|cell| cell.trim()).collect::<Vec<_>>(),
vec!["", "a", "`b | c`", "d \\| e", ""]
);
}
#[test]
fn table_row_normalization_escapes_raw_pipes_inside_inline_code() {
assert_eq!(
escape_inline_code_pipes_in_table_row("| A | `a | b` | ``x ` | y`` |\n").as_ref(),
"| A | `a \\| b` | ``x ` \\| y`` |\n"
);
}
#[test]
fn table_row_normalization_preserves_existing_escaped_pipes() {
assert_eq!(
escape_inline_code_pipes_in_table_row("| A | `a \\| b` |\n").as_ref(),
"| A | `a \\| b` |\n"
);
}
#[test]
fn table_row_normalization_ignores_unclosed_inline_code() {
assert_eq!(
escape_inline_code_pipes_in_table_row("| A | `a | b |\n").as_ref(),
"| A | `a | b |\n"
);
}
}

View File

@@ -0,0 +1,77 @@
use std::borrow::Cow;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
#[derive(Clone, Debug, Default, Eq, PartialEq)]
pub(super) struct TableCell {
lines: Vec<Line<'static>>,
}
impl TableCell {
pub(super) fn push_text(&mut self, text: &str, style: Style) {
self.push_span(Span::styled(text.to_string(), style));
}
pub(super) fn push_span(&mut self, span: Span<'static>) {
let content = span.content.to_string();
for (index, segment) in content.split('\n').enumerate() {
if index > 0 {
self.push_line_break();
}
if segment.is_empty() {
self.ensure_line();
continue;
}
let mut segment_span = span.clone();
segment_span.content = Cow::Owned(segment.to_string());
self.ensure_line().push_span(segment_span);
}
}
pub(super) fn push_line_break(&mut self) {
self.lines.push(Line::default());
}
pub(super) fn plain_text(&self) -> String {
self.lines
.iter()
.map(line_plain_text)
.collect::<Vec<_>>()
.join("\n")
}
pub(super) fn trimmed_plain_text(&self) -> String {
self.plain_text().trim().to_string()
}
pub(super) fn width(&self) -> usize {
self.lines.iter().map(Line::width).max().unwrap_or(0)
}
pub(super) fn is_blank(&self) -> bool {
self.lines
.iter()
.all(|line| line.spans.iter().all(|span| span.content.trim().is_empty()))
}
pub(super) fn lines(&self) -> &[Line<'static>] {
&self.lines
}
fn ensure_line(&mut self) -> &mut Line<'static> {
if self.lines.is_empty() {
self.lines.push(Line::default());
}
let last_index = self.lines.len() - 1;
&mut self.lines[last_index]
}
}
fn line_plain_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}

View File

@@ -0,0 +1,61 @@
use ratatui::style::Style;
use ratatui::text::Span;
use super::table_cell::TableCell;
#[derive(Debug, Default)]
pub(super) struct TableState {
pub(super) rows: Vec<Vec<TableCell>>,
current_row: Vec<TableCell>,
current_cell: TableCell,
in_cell: bool,
}
impl TableState {
pub(super) fn start_row(&mut self) {
self.current_row.clear();
}
pub(super) fn start_cell(&mut self) {
self.current_cell = TableCell::default();
self.in_cell = true;
}
pub(super) fn push_text(&mut self, text: &str, style: Style) {
if self.in_cell {
self.current_cell.push_text(text, style);
}
}
pub(super) fn push_span(&mut self, span: Span<'static>) {
if self.in_cell {
self.current_cell.push_span(span);
}
}
pub(super) fn push_html(&mut self, html: &str, style: Style) {
let trimmed = html.trim();
if matches!(
trimmed.to_ascii_lowercase().as_str(),
"<br>" | "<br/>" | "<br />"
) {
if self.in_cell {
self.current_cell.push_line_break();
}
} else {
self.push_text(html, style);
}
}
pub(super) fn end_cell(&mut self) {
self.current_row
.push(std::mem::take(&mut self.current_cell));
self.in_cell = false;
}
pub(super) fn end_row(&mut self) {
if !self.current_row.is_empty() {
self.rows.push(std::mem::take(&mut self.current_row));
}
}
}

View File

@@ -4,6 +4,7 @@ use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::text::Text;
use std::path::Path;
use unicode_width::UnicodeWidthStr;
use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
@@ -27,6 +28,496 @@ fn plain_lines(text: &Text<'_>) -> Vec<String> {
.collect()
}
fn find_span<'a>(text: &'a Text<'_>, content: &str) -> &'a Span<'a> {
text.lines
.iter()
.flat_map(|line| line.spans.iter())
.find(|span| span.content == content)
.unwrap_or_else(|| panic!("expected span containing {content:?} in {text:?}"))
}
fn assert_table_lines_leave_wrap_safety_column(lines: &[String], width: usize) {
assert!(
lines
.iter()
.filter(|line| line.contains('│') || line.contains('─'))
.all(|line| line.width() < width),
"table lines should not consume the final terminal column at width {width}: {lines:?}"
);
}
fn table_fixture() -> &'static str {
"| Area | Result |\n| --- | --- |\n| Streaming resize | This cell contains enough prose to wrap differently across widths. |\n| Scrollback preservation | SENTINEL_TABLE_VALUE_WITH_LONG_UNBREAKABLE_TOKEN |\n"
}
fn dense_large_table_fixture() -> String {
let mut markdown = String::from("| # | Token | Style | Link | Code Sample |\n| --- | --- | --- | --- | --- |\n");
for row in 1..=34 {
let token = match row {
1 => "Alpha".to_string(),
2 => "Beta".to_string(),
3 => "Gamma".to_string(),
24 => "Omega".to_string(),
_ => format!("Row {row}"),
};
let style = match row {
1 => "bold",
2 => "italic",
3 => "both",
21 => "a \\| b",
31 => "Mixed code",
33 => "Escaped \\*",
34 => "Done",
_ => "Normal",
};
let code = match row {
1 => "alpha()",
2 => "beta + 1",
3 => "Vec::<String>::new()",
21 => "split_fn",
_ => "row",
};
markdown.push_str(&format!(
"| {row} | {token} | {style} | row (https://example.com/{row}) | {code}_{row} |\n"
));
}
markdown
}
#[test]
fn table_resize_lifecycle_renderer_uses_thin_borders_and_fits_widths() {
for width in [36, 64, 96] {
let rendered = render_markdown_text_with_width_and_cwd(
table_fixture(),
Some(width),
/*cwd*/ None,
);
let lines = plain_lines(&rendered);
assert!(
lines.iter().any(|line| line.contains('┌')),
"expected thin table border at width {width}: {lines:?}"
);
assert!(
lines
.iter()
.filter(|line| line.contains('│') || line.contains('─'))
.all(|line| line.width() < width),
"table lines should fit inside width {width} without touching the final column: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.starts_with("Row ")),
"table should not use vertical fallback at width {width}: {lines:?}"
);
}
}
#[test]
fn table_resize_lifecycle_renderer_expansion_reduces_wrapping() {
let narrow = plain_lines(&render_markdown_text_with_width_and_cwd(
table_fixture(),
Some(36),
/*cwd*/ None,
));
let wide = plain_lines(&render_markdown_text_with_width_and_cwd(
table_fixture(),
Some(96),
/*cwd*/ None,
));
assert!(
narrow.len() > wide.len(),
"wider table should use space to reduce wrapping\nnarrow={narrow:?}\nwide={wide:?}"
);
}
#[test]
fn table_resize_lifecycle_renderer_uses_vertical_fallback_only_at_tiny_width() {
let rendered = render_markdown_text_with_width_and_cwd(
table_fixture(),
Some(18),
/*cwd*/ None,
);
let lines = plain_lines(&rendered);
assert!(
lines.iter().any(|line| line.contains("")),
"tiny width should trigger vertical fallback: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.starts_with("Row ")),
"vertical fallback should not render row headers: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 18);
}
#[test]
fn table_nested_in_blockquote_preserves_prefix() {
let markdown = "> | Area | Result |\n> | --- | --- |\n> | Resize | Keeps nested prefixes |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(40), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.filter(|line| line.contains('│') || line.contains('─'))
.all(|line| line.starts_with("> ")),
"nested blockquote table should preserve blockquote prefix: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 40);
}
#[test]
fn table_nested_in_list_preserves_prefix() {
let markdown =
"- item\n | Area | Result |\n | --- | --- |\n | Resize | Keeps nested prefixes |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(40), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.filter(|line| line.contains('│') || line.contains('─'))
.all(|line| line.starts_with(" ")),
"nested list table should preserve list indentation: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 40);
}
#[test]
fn table_readability_fallback_keeps_dense_large_table_boxed_when_wide() {
let rendered = render_markdown_text_with_width_and_cwd(
&dense_large_table_fixture(),
Some(140),
/*cwd*/ None,
);
let lines = plain_lines(&rendered);
assert!(
lines.iter().any(|line| line.contains('┌')),
"wide large table should remain boxed: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.starts_with("Row 31")),
"wide large table should not use vertical fallback: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 140);
}
#[test]
fn table_readability_fallback_keeps_dense_large_table_boxed_when_narrow() {
let rendered = render_markdown_text_with_width_and_cwd(
&dense_large_table_fixture(),
/*width*/ Some(64),
/*cwd*/ None,
);
let lines = plain_lines(&rendered);
assert!(
lines.iter().any(|line| line.starts_with(""))
&& lines
.iter()
.any(|line| line.contains("│ 31") && line.contains("Row 31")),
"last-resort fallback should keep narrow dense tables boxed with wrapping: {lines:?}"
);
assert!(
lines
.iter()
.all(|line| !line.contains("│ Token │ Row 31")),
"narrow dense table should not use key/value fallback: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 64);
}
#[test]
fn table_readability_fallback_wraps_boxed_values_before_fallback() {
let markdown = "| # | Color Sample |\n| --- | --- |\n| 31 | The color sample is the same as the others, but with a touch of white too. |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(30), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.contains("│ 31") && line.contains("The color sample")),
"first boxed value line should include the row value: {lines:?}"
);
assert!(
lines
.iter()
.skip(1)
.any(|line| line.contains("│ │") && line.contains("the same as the")),
"wrapped boxed value should align under its column: {lines:?}"
);
assert!(
lines
.iter()
.all(|line| !line.contains("│ Color Sample │ The color")),
"wrappable two-column table should not use key/value fallback: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 30);
}
#[test]
fn table_readability_fallback_keeps_small_status_table_boxed_when_narrow() {
let markdown = "| ID | Status | Owner | Summary |\n| --- | --- | --- | --- |\n| 1 | ✅ Done | Ana | Added markdown_table parsing |\n| 2 | 🟡 Review | Ben | Checks links like RFC (https://example.com/rfc) |\n| 3 | 🔴 Blocked | ci-bot | Fails on foo \\| bar escaping |\n| 4 | ⚪ Planned | Dana | Needs snapshot coverage |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(64), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines.iter().any(|line| line.contains('┌')),
"small status table should remain boxed at narrow widths: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 64);
}
#[test]
fn table_readability_fallback_keeps_video_small_table_boxed() {
let markdown = "| Emoji | Title | Content | State | Notes |\n| --- | --- | --- | --- | --- |\n| 🚀 | Alpha | **bold** text | done | launch |\n| 🎯 | Beta | *italic* text | pending | target |\n| 💡 | Gamma | `inline code` | review | snippet |\n| 🔗 | Delta | [delta](https://example.com/delta) | done | link cell |\n| ✨ | Epsilon | ~~strike~~ | done | cleanup |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(64), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.starts_with("│ 🚀") && line.contains("Alpha")),
"video small table should render as a boxed table at 64 columns: {lines:?}"
);
assert!(
lines
.iter()
.all(|line| !line.contains("│ Emoji │ 🚀")),
"video small table should not use key/value fallback at 64 columns: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 64);
}
#[test]
fn table_readability_fallback_keeps_tiny_table_boxed() {
let markdown = "| Tiny | Table |\n| --- | --- |\n| A | B |\n| C | D |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(40), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines.iter().any(|line| line.contains('┌')),
"tiny table should remain boxed when there is enough width: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 40);
}
#[test]
fn table_readability_fallback_keeps_emoji_near_fit_boxed() {
let markdown = "| Feature | Markdown Coverage | Sample Output |\n| --- | --- | --- |\n| Emoji | 😎 ✅ 🧩 | Visual glyphs |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(41), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.contains("│ Emoji") && line.contains("😎 ✅ 🧩")),
"emoji-heavy near-fit table should remain boxed: {lines:?}"
);
assert!(
lines
.iter()
.all(|line| !line.contains("│ Markdown Coverage │ 😎 ✅ 🧩")),
"emoji-heavy near-fit table should not use key/value fallback: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 41);
}
#[test]
fn table_emoji_rows_reserve_extra_wrap_safety_column() {
let width = 84;
let markdown = "| Scenario | ✅ Pass Case | ⚠️ Edge Case | ❌ Fail Case |\n|---|---|---|---|\n| Links | [short](https://example.com) | [query params](https://example.com?q=a&b=c) | `[broken](missing` |\n| Code | `let x = 1;` | `Vec<Result<T, E>>` | unclosed backtick |\n| Emoji | 😀 | 🧑‍💻 combined glyph | mojibake |\n| Pipes | `a \\| b` | escaped pipe inside code | raw `a | b` can split |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(width), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.filter(|line| line.contains('│') || line.contains('─'))
.all(|line| line.width() <= width - 2),
"emoji-heavy tables should leave two wrap-safety columns at width {width}: {lines:?}"
);
}
#[test]
fn table_readability_fallback_uses_vertical_when_boxed_is_impossible() {
let markdown = "| A | B | C | D | E |\n| --- | --- | --- | --- | --- |\n| one | two | three | four | five |\n| six | seven | eight | nine | ten |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(20), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.contains("A │ one")),
"impossibly narrow table should use key/value fallback: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains('┼')),
"vertical fallback should separate source rows: {lines:?}"
);
assert_table_lines_leave_wrap_safety_column(&lines, /*width*/ 20);
}
#[test]
fn table_inline_links_and_html_breaks_stay_inside_table() {
let markdown = "| A | B |\n|---|---|\n| [link](https://example.com) | [CLI docs](https://example.com/cli) |\n| one<br>two | three<br>four |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(72), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.filter(|line| !line.trim().is_empty())
.all(|line| line.contains('│') || line.contains('─')),
"table inline content should not leak outside table: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("link (https://example.com)")),
"link destination should render in the table cell: {lines:?}"
);
assert!(
lines
.iter()
.any(|line| line.contains("CLI docs (https://example.com/cli)")),
"second link destination should render in the table cell: {lines:?}"
);
assert!(
lines.iter().any(|line| line.contains("one"))
&& lines.iter().any(|line| line.contains("two"))
&& lines.iter().all(|line| !line.contains("<br>")),
"HTML breaks should render as table cell line breaks: {lines:?}"
);
}
#[test]
fn table_raw_pipes_inside_inline_code_stay_inside_cell() {
let markdown = "| Scenario | ✅ Pass Case | ⚠️ Edge Case | ❌ Fail Case |\n|---|---|---|---|\n| Pipes | `a \\| b` | escaped pipe inside code | raw `a | b` can split |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(160), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines.iter().any(|line| line.contains("raw a | b can split")),
"raw pipe inside inline code should remain in the final cell: {lines:?}"
);
assert!(
lines
.iter()
.filter(|line| line.contains('│'))
.all(|line| line.matches('│').count() == 5),
"table should remain four columns wide: {lines:?}"
);
assert_snapshot!("table_raw_pipes_inside_inline_code", lines.join("\n"));
}
#[test]
fn table_preserves_inline_styles_in_boxed_layout() {
let markdown = "| Feature | Sample |\n| --- | --- |\n| Code | `run just fmt` |\n| Bold | **strong** |\n| Italic | *soft* |\n| Strike | ~~gone~~ |\n| Link | [docs](https://example.com/docs) |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(96), /*cwd*/ None);
assert_eq!(find_span(&rendered, "run just fmt").style, "x".cyan().style);
assert_eq!(find_span(&rendered, "strong").style, "x".bold().style);
assert_eq!(find_span(&rendered, "soft").style, "x".italic().style);
assert_eq!(
find_span(&rendered, "gone").style,
"x".crossed_out().style
);
assert_eq!(
find_span(&rendered, "https://example.com/docs").style,
"x".cyan().underlined().style
);
}
#[test]
fn table_preserves_inline_styles_in_vertical_layout() {
let markdown = "| # | Feature | Sample | Notes | Extra | A | B | C |\n| --- | --- | --- | --- | --- | --- | --- | --- |\n| 1 | Code | `cargo test` | **done** | x | y | z | q |\n";
let rendered =
render_markdown_text_with_width_and_cwd(markdown, /*width*/ Some(30), /*cwd*/ None);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.contains("Sample │ cargo")),
"narrow table should render as vertical key/value rows: {lines:?}"
);
assert_eq!(find_span(&rendered, "cargo test").style, "x".cyan().style);
assert_eq!(find_span(&rendered, "done").style, "x".bold().style);
}
#[test]
fn table_local_file_links_match_regular_response_display() {
let markdown = "| Path |\n| --- |\n| [label](file:///Users/example/code/codex/codex-rs/tui/src/markdown_render.rs#L74C3) |\n";
let rendered = render_markdown_text_with_width_and_cwd(
markdown,
/*width*/ None,
Some(Path::new("/Users/example/code/codex")),
);
let lines = plain_lines(&rendered);
assert!(
lines
.iter()
.any(|line| line.contains("codex-rs/tui/src/markdown_render.rs:74:3")),
"local table link should show the resolved target: {lines:?}"
);
assert!(
lines.iter().all(|line| !line.contains("label")),
"local table link should suppress the markdown label: {lines:?}"
);
assert_eq!(
find_span(&rendered, "codex-rs/tui/src/markdown_render.rs:74:3").style,
"x".cyan().style
);
}
#[test]
fn table_boundary_normalization_does_not_mutate_code_blocks() {
let markdown = "```\n| A | B |\n| --- | --- |\n```\nAfter.\n";
let rendered = render_markdown_text(markdown);
assert_eq!(
rendered,
Text::from_iter([
Line::from_iter(["", "| A | B |"]),
Line::from_iter(["", "| --- | --- |"]),
Line::default(),
Line::from("After."),
]),
);
}
#[test]
fn table_boundary_normalization_does_not_mutate_indented_code_blocks() {
let markdown = " | A | B |\n | --- | --- |\nAfter.\n";
let rendered = render_markdown_text(markdown);
assert_eq!(
rendered,
Text::from_iter([
Line::from_iter([" ", "| A | B |"]),
Line::from_iter([" ", "| --- | --- |"]),
Line::default(),
Line::from("After."),
]),
);
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());

View File

@@ -28,9 +28,13 @@ Image: alt text
———
Table below (alignment test):
| Left | Center | Right |
|:-----|:------:|------:|
| a | b | c |
┌──────┬────────┬───────┐
│ Left │ Center │ Right │
├──────┼────────┼───────┤
│ a │ b │ c │
└──────┴────────┴───────┘
Inline HTML: <sup>sup</sup> and <sub>sub</sub>.
HTML block:
<div style="border:1px solid #ccc;padding:2px">inline block</div>

View File

@@ -0,0 +1,9 @@
---
source: tui/src/markdown_render_tests.rs
expression: "lines.join(\"\\n\")"
---
┌──────────┬──────────────┬──────────────────────────┬─────────────────────┐
│ Scenario │ ✅ Pass Case │ ⚠️ Edge Case │ ❌ Fail Case │
├──────────┼──────────────┼──────────────────────────┼─────────────────────┤
│ Pipes │ a | b │ escaped pipe inside code │ raw a | b can split │
└──────────┴──────────────┴──────────────────────────┴─────────────────────┘

View File

@@ -7,8 +7,10 @@
//! returns the accumulated source to the app for consolidation.
//!
//! Width changes are handled by re-rendering from source and rebuilding only the not-yet-emitted
//! queue. Already emitted rows stay emitted until the app-level transcript reflow rebuilds the full
//! scrollback from finalized cells.
//! queue. Already emitted rows stay emitted, but the last emitted stable stream cell carries a
//! source snapshot so app-level transcript reflow can rebuild from markdown instead of re-wrapping
//! stale table rows. Finalization still consolidates the stream into one finalized source-backed
//! cell.
use crate::history_cell::HistoryCell;
use crate::history_cell::{self};
@@ -23,21 +25,23 @@ use std::time::Duration;
use std::time::Instant;
use super::StreamState;
use super::source_partition::partition_source;
/// Shared source-retaining stream state for assistant and plan output.
///
/// `raw_source` is the markdown source that has crossed a newline boundary and can be rendered
/// deterministically. `rendered_lines` is the current-width render of that source. `enqueued_len`
/// tracks how much of that render has been offered to the commit queue, while `emitted_len` tracks
/// how much has actually reached history cells. Keeping those counters separate lets width changes
/// rebuild pending output without duplicating lines that are already visible.
/// deterministically. Complete top-level markdown blocks are queued for stable history emission.
/// The final block stays in `active_tail_lines` so it can be redrawn on resize until a later block
/// proves it stable or finalization consolidates the whole source into a resize-aware history cell.
struct StreamCore {
state: StreamState,
width: Option<usize>,
raw_source: String,
stable_source_end: usize,
rendered_lines: Vec<Line<'static>>,
enqueued_len: usize,
emitted_len: usize,
active_tail_lines: Vec<Line<'static>>,
cwd: PathBuf,
}
@@ -47,9 +51,11 @@ impl StreamCore {
state: StreamState::new(width, cwd),
width,
raw_source: String::with_capacity(1024),
stable_source_end: 0,
rendered_lines: Vec::with_capacity(64),
enqueued_len: 0,
emitted_len: 0,
active_tail_lines: Vec::with_capacity(16),
cwd: cwd.to_path_buf(),
}
}
@@ -64,8 +70,7 @@ impl StreamCore {
&& let Some(committed_source) = self.state.collector.commit_complete_source()
{
self.raw_source.push_str(&committed_source);
self.recompute_render();
return self.sync_queue_to_render();
return self.sync_from_source();
}
false
@@ -106,6 +111,14 @@ impl StreamCore {
step
}
fn emitted_stable_source(&self) -> Option<&str> {
if self.stable_source_end > 0 && self.emitted_len >= self.rendered_lines.len() {
Some(&self.raw_source[..self.stable_source_end])
} else {
None
}
}
fn queued_lines(&self) -> usize {
self.state.queued_len()
}
@@ -123,31 +136,27 @@ impl StreamCore {
return;
}
let had_pending_queue = self.state.queued_len() > 0;
self.width = width;
self.state.collector.set_width(width);
if self.raw_source.is_empty() {
return;
}
self.recompute_render();
let had_pending_queue = self.state.queued_len() > 0;
self.recompute_stable_render();
self.emitted_len = self.emitted_len.min(self.rendered_lines.len());
if had_pending_queue
&& self.emitted_len == self.rendered_lines.len()
&& self.emitted_len > 0
{
// If wrapped remainder compresses into fewer lines at the new width,
// keep at least one line un-emitted so pre-resize pending content is
// not skipped permanently.
// If wrapped remainder compresses into fewer lines at the new width, keep at least one
// line un-emitted so pre-resize pending content is not skipped permanently.
self.emitted_len -= 1;
}
self.state.clear_queue();
if self.emitted_len > 0 && !had_pending_queue {
self.enqueued_len = self.rendered_lines.len();
self.render_active_tail();
return;
}
self.rebuild_queue_from_render();
self.render_active_tail();
}
fn clear_queue(&mut self) {
@@ -158,26 +167,39 @@ impl StreamCore {
fn reset(&mut self) {
self.state.clear();
self.raw_source.clear();
self.stable_source_end = 0;
self.rendered_lines.clear();
self.enqueued_len = 0;
self.emitted_len = 0;
self.active_tail_lines.clear();
}
fn recompute_render(&mut self) {
fn sync_from_source(&mut self) -> bool {
let partition = partition_source(&self.raw_source);
let enqueued = if partition.stable_end > self.stable_source_end {
self.stable_source_end = partition.stable_end;
self.recompute_stable_render();
self.sync_queue_to_render()
} else {
false
};
self.render_active_tail();
enqueued
}
fn recompute_stable_render(&mut self) {
self.rendered_lines.clear();
if self.stable_source_end == 0 {
return;
}
append_markdown(
&self.raw_source,
&self.raw_source[..self.stable_source_end],
self.width,
Some(self.cwd.as_path()),
&mut self.rendered_lines,
);
}
/// Append newly rendered lines to the live queue without replaying already queued rows.
///
/// Width changes can make the rendered line count smaller than the previous queue boundary; in
/// that case the only safe option is rebuilding the queue from `emitted_len`, because slicing
/// from the stale `enqueued_len` would skip pending source.
fn sync_queue_to_render(&mut self) -> bool {
let target_len = self.rendered_lines.len().max(self.emitted_len);
if target_len < self.enqueued_len {
@@ -195,10 +217,6 @@ impl StreamCore {
true
}
/// Rebuild the pending live queue from the current render and current emitted position.
///
/// This is used when resize invalidates queued wrapping. It must never enqueue rows before
/// `emitted_len`, because those rows have already been inserted into terminal history.
fn rebuild_queue_from_render(&mut self) {
self.state.clear_queue();
let target_len = self.rendered_lines.len().max(self.emitted_len);
@@ -208,14 +226,33 @@ impl StreamCore {
}
self.enqueued_len = target_len;
}
fn render_active_tail(&mut self) {
self.active_tail_lines.clear();
if self.stable_source_end >= self.raw_source.len() {
return;
}
append_markdown(
&self.raw_source[self.stable_source_end..],
self.width,
Some(self.cwd.as_path()),
&mut self.active_tail_lines,
);
}
fn active_tail_lines(&self) -> &[Line<'static>] {
&self.active_tail_lines
}
}
/// Controls newline-gated streaming for assistant messages.
///
/// The controller emits transient `AgentMessageCell`s for live display and returns raw markdown
/// source on `finalize` so the app can replace those transient cells with a source-backed
/// `AgentMarkdownCell`. Callers should use `set_width` on terminal resize; rebuilding the queue
/// from already emitted cells would duplicate output instead of preserving the stream position.
/// `AgentMarkdownCell`. Cells emitted after a stable prefix is fully drained carry that stable
/// source as a resize-reflow repair hint. Callers should use `set_width` on terminal resize;
/// rebuilding the queue from already emitted cells would duplicate output instead of preserving the
/// stream position.
pub(crate) struct StreamController {
core: StreamCore,
header_emitted: bool,
@@ -242,6 +279,20 @@ impl StreamController {
self.core.push_delta(delta)
}
pub(crate) fn active_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
if self.core.queued_lines() > 0 {
return None;
}
let lines = self.core.active_tail_lines();
if lines.is_empty() {
return None;
}
Some(Box::new(history_cell::AgentMessageCell::new(
lines.to_vec(),
!self.header_emitted,
)))
}
/// Finish the stream and return the final transient cell plus accumulated markdown source.
///
/// The source is `None` only when the stream never accumulated content. Callers that discard the
@@ -255,14 +306,15 @@ impl StreamController {
}
let source = std::mem::take(&mut self.core.raw_source);
let out = self.emit(remaining);
let out = self.emit(remaining, Some(source.clone()));
self.core.reset();
(out, Some(source))
}
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.core.tick();
(self.emit(step), self.core.is_idle())
let source = self.core.emitted_stable_source().map(str::to_string);
(self.emit(step, source), self.core.is_idle())
}
pub(crate) fn on_commit_tick_batch(
@@ -270,7 +322,8 @@ impl StreamController {
max_lines: usize,
) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.core.tick_batch(max_lines);
(self.emit(step), self.core.is_idle())
let source = self.core.emitted_stable_source().map(str::to_string);
(self.emit(step, source), self.core.is_idle())
}
pub(crate) fn queued_lines(&self) -> usize {
@@ -289,15 +342,32 @@ impl StreamController {
self.core.set_width(width);
}
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
fn emit(
&mut self,
lines: Vec<Line<'static>>,
markdown_source: Option<String>,
) -> Option<Box<dyn HistoryCell>> {
if lines.is_empty() {
return None;
}
Some(Box::new(history_cell::AgentMessageCell::new(lines, {
let header_emitted = self.header_emitted;
self.header_emitted = true;
!header_emitted
})))
let header_emitted = self.header_emitted;
self.header_emitted = true;
let is_first_line = !header_emitted;
if let Some(source) = markdown_source {
Some(Box::new(
history_cell::AgentMessageCell::new_with_markdown_source(
lines,
is_first_line,
source,
self.core.cwd.as_path(),
),
))
} else {
Some(Box::new(history_cell::AgentMessageCell::new(
lines,
is_first_line,
)))
}
}
}
@@ -333,6 +403,41 @@ impl PlanStreamController {
self.core.push_delta(delta)
}
pub(crate) fn active_tail_cell(&self) -> Option<Box<dyn HistoryCell>> {
if self.core.queued_lines() > 0 {
return None;
}
let lines = self.core.active_tail_lines();
if lines.is_empty() {
return None;
}
let mut out_lines: Vec<Line<'static>> = Vec::with_capacity(lines.len() + 3);
let is_stream_continuation = self.header_emitted;
if !self.header_emitted {
out_lines.push(vec!["".dim(), "Proposed Plan".bold()].into());
out_lines.push(Line::from(" "));
}
let mut plan_lines: Vec<Line<'static>> = Vec::with_capacity(lines.len() + 1);
if !self.top_padding_emitted {
plan_lines.push(Line::from(" "));
}
plan_lines.extend(lines.iter().cloned());
let plan_style = proposed_plan_style();
out_lines.extend(
prefix_lines(plan_lines, " ".into(), " ".into())
.into_iter()
.map(|line| line.style(plan_style)),
);
Some(Box::new(history_cell::new_proposed_plan_stream(
out_lines,
is_stream_continuation,
)))
}
/// Finish the plan stream and return the final transient cell plus accumulated markdown source.
///
/// The returned source is consumed by app-level consolidation to create the source-backed
@@ -345,15 +450,20 @@ impl PlanStreamController {
}
let source = std::mem::take(&mut self.core.raw_source);
let out = self.emit(remaining, /*include_bottom_padding*/ true);
let out = self.emit(
remaining,
/*include_bottom_padding*/ true,
Some(source.clone()),
);
self.core.reset();
(out, Some(source))
}
pub(crate) fn on_commit_tick(&mut self) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.core.tick();
let source = self.core.emitted_stable_source().map(str::to_string);
(
self.emit(step, /*include_bottom_padding*/ false),
self.emit(step, /*include_bottom_padding*/ false, source),
self.core.is_idle(),
)
}
@@ -363,8 +473,9 @@ impl PlanStreamController {
max_lines: usize,
) -> (Option<Box<dyn HistoryCell>>, bool) {
let step = self.core.tick_batch(max_lines);
let source = self.core.emitted_stable_source().map(str::to_string);
(
self.emit(step, /*include_bottom_padding*/ false),
self.emit(step, /*include_bottom_padding*/ false, source),
self.core.is_idle(),
)
}
@@ -389,6 +500,7 @@ impl PlanStreamController {
&mut self,
lines: Vec<Line<'static>>,
include_bottom_padding: bool,
markdown_source: Option<String>,
) -> Option<Box<dyn HistoryCell>> {
if lines.is_empty() && !include_bottom_padding {
return None;
@@ -419,10 +531,22 @@ impl PlanStreamController {
.collect::<Vec<_>>();
out_lines.extend(plan_lines);
Some(Box::new(history_cell::new_proposed_plan_stream(
out_lines,
is_stream_continuation,
)))
if let Some(source) = markdown_source {
Some(Box::new(
history_cell::new_proposed_plan_stream_with_markdown_source(
out_lines,
is_stream_continuation,
source,
self.core.cwd.as_path(),
include_bottom_padding,
),
))
} else {
Some(Box::new(history_cell::new_proposed_plan_stream(
out_lines,
is_stream_continuation,
)))
}
}
}
@@ -455,6 +579,16 @@ mod tests {
.collect()
}
fn active_tail_plain_strings(ctrl: &StreamController) -> Vec<String> {
ctrl.active_tail_cell()
.map(|cell| lines_to_plain_strings(&cell.transcript_lines(u16::MAX)))
.unwrap_or_default()
}
fn table_source() -> &'static str {
"| Area | Result |\n| --- | --- |\n| Streaming resize | This cell contains enough prose to wrap differently across widths. |\n| Scrollback preservation | SENTINEL_TABLE_VALUE_WITH_LONG_UNBREAKABLE_TOKEN |\n"
}
fn collect_streamed_lines(deltas: &[&str], width: Option<usize>) -> Vec<String> {
let mut ctrl = stream_controller(width);
let mut lines = Vec::new();
@@ -497,7 +631,8 @@ mod tests {
#[test]
fn controller_set_width_rebuilds_queued_lines() {
let mut ctrl = stream_controller(Some(120));
let delta = "This is a long line that should wrap into multiple rows when resized.\n";
let delta =
"This is a long line that should wrap into multiple rows when resized.\n\nnext\n";
assert!(ctrl.push(delta));
assert_eq!(ctrl.queued_lines(), 1);
@@ -519,8 +654,7 @@ mod tests {
#[test]
fn controller_set_width_no_duplicate_after_emit() {
let mut ctrl = stream_controller(Some(120));
let line =
"This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n";
let line = "This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n\nnext\n";
ctrl.push(line);
let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX);
assert!(cell.is_some(), "expected emitted cell");
@@ -535,10 +669,157 @@ mod tests {
);
}
#[test]
fn table_resize_lifecycle_streaming_table_stays_active_tail_until_next_block() {
let mut ctrl = stream_controller(Some(48));
assert!(!ctrl.push("| Area | Result |\n"));
assert_eq!(ctrl.queued_lines(), 0);
let before_delimiter = active_tail_plain_strings(&ctrl);
assert!(
before_delimiter
.iter()
.any(|line| line.contains("| Area | Result |")),
"header should be visible as mutable plain markdown before delimiter: {before_delimiter:?}",
);
assert!(!ctrl.push("| --- | --- |\n"));
assert!(!ctrl.push("| One | Two |\n"));
let after_delimiter = active_tail_plain_strings(&ctrl);
assert!(
after_delimiter.iter().any(|line| line.contains('┌')),
"completed table row should make active tail render as a table: {after_delimiter:?}",
);
assert_eq!(
ctrl.queued_lines(),
0,
"table should not be committed while it is the final block"
);
assert!(ctrl.push("\nAfter table.\n"));
assert!(
ctrl.queued_lines() > 0,
"table should enter stable queue after a later block appears"
);
assert_eq!(
active_tail_plain_strings(&ctrl),
Vec::<String>::new(),
"later block should wait behind the queued stable table",
);
let (_cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ usize::MAX);
assert!(idle);
let new_tail = active_tail_plain_strings(&ctrl);
assert!(
new_tail.iter().any(|line| line.contains("After table.")),
"later block should become active tail after queued table drains: {new_tail:?}",
);
}
#[test]
fn active_tail_waits_for_queued_stable_blocks() {
let mut ctrl = stream_controller(/*width*/ Some(80));
assert!(ctrl.push("first\n\nsecond\n"));
assert_eq!(
active_tail_plain_strings(&ctrl),
Vec::<String>::new(),
"new tail must not render ahead of queued stable content",
);
let (cell, idle) = ctrl.on_commit_tick();
let emitted = lines_to_plain_strings(
&cell
.expect("expected queued stable block to emit first")
.transcript_lines(u16::MAX),
);
assert_eq!(emitted, vec!["• first"]);
assert!(idle);
assert_eq!(active_tail_plain_strings(&ctrl), vec![" second"]);
}
#[test]
fn plan_active_tail_waits_for_queued_stable_blocks() {
let mut ctrl = plan_stream_controller(/*width*/ Some(80));
assert!(ctrl.push("first\n\nsecond\n"));
assert!(
ctrl.active_tail_cell().is_none(),
"new plan tail must not render ahead of queued stable content",
);
let (cell, idle) = ctrl.on_commit_tick();
let emitted = lines_to_plain_strings(
&cell
.expect("expected queued stable plan block to emit first")
.transcript_lines(u16::MAX),
);
assert!(
emitted.iter().any(|line| line.contains("Proposed Plan"))
&& emitted.iter().any(|line| line.contains("first")),
"first plan block should emit before active tail: {emitted:?}",
);
assert!(idle);
let tail = lines_to_plain_strings(
&ctrl
.active_tail_cell()
.expect("expected active tail after queue drains")
.transcript_lines(u16::MAX),
);
assert!(
tail.iter().all(|line| !line.contains("Proposed Plan"))
&& tail.iter().any(|line| line.contains("second")),
"tail should continue the existing plan cell without a duplicate header: {tail:?}",
);
}
#[test]
fn plan_stream_cells_carry_stable_source_snapshots() {
let mut ctrl = plan_stream_controller(/*width*/ Some(80));
assert!(ctrl.push(&format!("{}\nAfter table.\n", table_source())));
let (cell, _idle) = ctrl.on_commit_tick_batch(usize::MAX);
let cell = cell.expect("expected queued stable plan table to emit");
let plan_cell = cell
.as_any()
.downcast_ref::<history_cell::ProposedPlanStreamCell>()
.expect("expected proposed plan stream cell");
let (source, _cwd, include_bottom_padding) = plan_cell
.markdown_source()
.expect("stable plan stream cell should carry markdown source");
assert_eq!(source, table_source());
assert!(!include_bottom_padding);
}
#[test]
fn table_resize_lifecycle_streaming_resize_updates_active_tail_width() {
let mut ctrl = stream_controller(Some(36));
assert!(!ctrl.push(table_source()));
let narrow = active_tail_plain_strings(&ctrl);
ctrl.set_width(Some(96));
let wide = active_tail_plain_strings(&ctrl);
assert!(
narrow.len() > wide.len(),
"active table tail should rerender and use wider width\nnarrow={narrow:?}\nwide={wide:?}",
);
assert!(
wide.iter().any(|line| line.contains('┌')),
"active tail should remain table-shaped after resize: {wide:?}",
);
}
#[test]
fn controller_tick_batch_zero_is_noop() {
let mut ctrl = stream_controller(Some(80));
assert!(ctrl.push("line one\n"));
assert!(ctrl.push("line one\n\nnext\n"));
assert_eq!(ctrl.queued_lines(), 1);
let (cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ 0);
@@ -554,7 +835,7 @@ mod tests {
#[test]
fn controller_finalize_returns_raw_source_for_consolidation() {
let mut ctrl = stream_controller(Some(80));
assert!(ctrl.push("hello\n"));
ctrl.push("hello\n");
let (_cell, source) = ctrl.finalize();
assert_eq!(source, Some("hello\n".to_string()));
}
@@ -562,7 +843,7 @@ mod tests {
#[test]
fn plan_controller_finalize_returns_raw_source_for_consolidation() {
let mut ctrl = plan_stream_controller(Some(80));
assert!(ctrl.push("- step\n"));
ctrl.push("- step\n");
let (_cell, source) = ctrl.finalize();
assert_eq!(source, Some("- step\n".to_string()));
}

View File

@@ -20,6 +20,7 @@ use crate::markdown_stream::MarkdownStreamCollector;
pub(crate) mod chunking;
pub(crate) mod commit_tick;
pub(crate) mod controller;
pub(crate) mod source_partition;
struct QueuedLine {
line: Line<'static>,

View File

@@ -0,0 +1,141 @@
use std::ops::Range;
use pulldown_cmark::Event;
use pulldown_cmark::Options;
use pulldown_cmark::Parser;
use pulldown_cmark::Tag;
use pulldown_cmark::TagEnd;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SourcePartition {
pub(crate) stable_end: usize,
pub(crate) stable_blocks: Vec<Range<usize>>,
pub(crate) tail: Range<usize>,
}
pub(crate) fn partition_source(source: &str) -> SourcePartition {
let blocks = top_level_blocks(source);
let stable_end = if blocks.len() >= 2 {
blocks[blocks.len() - 2].end
} else {
0
};
let stable_blocks = blocks
.iter()
.filter(|range| range.end <= stable_end)
.cloned()
.collect();
SourcePartition {
stable_end,
stable_blocks,
tail: stable_end..source.len(),
}
}
fn top_level_blocks(source: &str) -> Vec<Range<usize>> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
options.insert(Options::ENABLE_TABLES);
let mut blocks: Vec<Range<usize>> = Vec::new();
let mut depth = 0usize;
let mut block_start: Option<usize> = None;
for (event, range) in Parser::new_ext(source, options).into_offset_iter() {
match event {
Event::Start(tag) if is_block_start(&tag) => {
if depth == 0 {
block_start = Some(range.start);
}
depth += 1;
}
Event::End(tag) if is_block_end(tag) => {
if depth > 0 {
depth -= 1;
if depth == 0
&& let Some(start) = block_start.take()
{
blocks.push(start..range.end);
}
}
}
Event::Rule if depth == 0 => {
blocks.push(range.clone());
}
_ => {}
}
}
blocks
}
fn is_block_start(tag: &Tag<'_>) -> bool {
matches!(
tag,
Tag::Paragraph
| Tag::Heading { .. }
| Tag::BlockQuote
| Tag::CodeBlock(_)
| Tag::List(_)
| Tag::HtmlBlock
| Tag::FootnoteDefinition(_)
| Tag::Table(_)
| Tag::MetadataBlock(_)
)
}
fn is_block_end(tag: TagEnd) -> bool {
matches!(
tag,
TagEnd::Paragraph
| TagEnd::Heading(_)
| TagEnd::BlockQuote
| TagEnd::CodeBlock
| TagEnd::List(_)
| TagEnd::HtmlBlock
| TagEnd::FootnoteDefinition
| TagEnd::Table
| TagEnd::MetadataBlock(_)
)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn single_table_remains_tail() {
let source = "| A | B |\n| --- | --- |\n| 1 | 2 |\n";
let partition = partition_source(source);
assert_eq!(partition.stable_end, 0);
assert_eq!(&source[partition.tail], source);
}
#[test]
fn table_becomes_stable_after_later_block() {
let source = "| A | B |\n| --- | --- |\n| 1 | 2 |\n\nDone.\n";
let partition = partition_source(source);
assert_eq!(
&source[..partition.stable_end],
"| A | B |\n| --- | --- |\n| 1 | 2 |\n"
);
assert_eq!(&source[partition.tail], "\nDone.\n");
assert_eq!(partition.stable_blocks.len(), 1);
}
#[test]
fn fenced_code_with_pipes_is_one_tail_block() {
let source = "```\n| A | B |\n| --- | --- |\n```\n";
let partition = partition_source(source);
assert_eq!(partition.stable_end, 0);
assert_eq!(&source[partition.tail], source);
}
}

View File

@@ -25,15 +25,28 @@ pub struct VT100Backend {
impl VT100Backend {
/// Creates a new `TestBackend` with the specified width and height.
pub fn new(width: u16, height: u16) -> Self {
Self::new_with_scrollback(width, height, /*scrollback_len*/ 0)
}
pub fn new_with_scrollback(width: u16, height: u16, scrollback_len: usize) -> Self {
crossterm::style::force_color_output(true);
Self {
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(height, width, 0)),
crossterm_backend: CrosstermBackend::new(vt100::Parser::new(
height,
width,
scrollback_len,
)),
}
}
pub fn vt100(&self) -> &vt100::Parser {
self.crossterm_backend.writer()
}
#[allow(dead_code)]
pub fn vt100_mut(&mut self) -> &mut vt100::Parser {
self.crossterm_backend.writer_mut()
}
}
impl Write for VT100Backend {

View File

@@ -27,7 +27,6 @@ pub(crate) const TRANSCRIPT_REFLOW_DEBOUNCE: Duration = Duration::from_millis(75
pub(crate) struct TranscriptReflowState {
last_observed_width: Option<u16>,
last_reflow_width: Option<u16>,
pending_reflow_width: Option<u16>,
pending_until: Option<Instant>,
ran_during_stream: bool,
resize_requested_during_stream: bool,
@@ -66,38 +65,17 @@ impl TranscriptReflowState {
/// the resize event, so the follow-up draw must be able to request one more reflow even if
/// the observed-width tracker already saw that value.
pub(crate) fn reflow_needed_for_width(&self, width: u16) -> bool {
self.last_reflow_width != Some(width) && self.pending_reflow_width != Some(width)
}
/// Schedule a trailing-debounced reflow and return whether it should run immediately.
///
/// Repeated resize events push the deadline out so dragging a terminal edge rebuilds scrollback
/// at the final observed width rather than at intermediate widths. `target_width` is present
/// only for width-changing rebuilds; height-only exposure still needs a rebuild, but it must not
/// suppress a later width repair for the same draw cycle.
pub(crate) fn schedule_debounced(&mut self, target_width: Option<u16>) -> bool {
let now = Instant::now();
if let Some(target_width) = target_width {
self.pending_reflow_width = Some(target_width);
}
self.pending_until = Some(now + TRANSCRIPT_REFLOW_DEBOUNCE);
false
self.last_reflow_width != Some(width)
}
/// Schedule an immediate reflow for the next draw opportunity.
///
/// This is used after stream consolidation when waiting for the debounce interval would leave
/// visible terminal-wrapped stream rows in the finalized transcript.
/// This is used for terminal resize and stream consolidation so terminal-owned wrapping is
/// replaced by source-backed transcript rendering without a stale intermediate frame.
pub(crate) fn schedule_immediate(&mut self) {
self.pending_reflow_width = None;
self.pending_until = Some(Instant::now());
}
#[cfg(test)]
pub(crate) fn set_due_for_test(&mut self) {
self.pending_until = Some(Instant::now() - Duration::from_millis(1));
}
pub(crate) fn pending_is_due(&self, now: Instant) -> bool {
self.pending_until.is_some_and(|deadline| now >= deadline)
}
@@ -112,14 +90,13 @@ impl TranscriptReflowState {
pub(crate) fn clear_pending_reflow(&mut self) {
self.pending_until = None;
self.pending_reflow_width = None;
}
/// Remember the terminal width that actually rebuilt transcript scrollback.
///
/// Resize scheduling is driven by observed widths, but debounced redraws may run before a
/// terminal emulator has settled on its final size. Keeping the rendered width separate avoids
/// confusing "seen during a draw" with "scrollback has been repaired at this width".
/// Resize scheduling is driven by observed widths, but a terminal emulator may settle on its
/// final size after an earlier draw. Keeping the rendered width separate avoids confusing
/// "seen during a draw" with "scrollback has been repaired at this width".
pub(crate) fn mark_reflowed_width(&mut self, width: u16) -> bool {
self.last_reflow_width.replace(width) != Some(width)
}
@@ -135,10 +112,9 @@ impl TranscriptReflowState {
/// Remember that the terminal width changed while streaming or pre-consolidation cells existed.
///
/// This captures the case where the debounce did not fire before the stream finished. Without
/// This captures the case where stream consolidation finishes after a resize request. Without
/// this flag, consolidation could complete without the final source-backed resize repair.
/// Marking the request rather than forcing immediate rendering keeps resize drag behavior
/// debounced while still guaranteeing that finalized stream cells replace transient rows.
/// Marking the request guarantees finalized stream cells replace transient rows.
pub(crate) fn mark_resize_requested_during_stream(&mut self) {
self.resize_requested_during_stream = true;
}
@@ -179,32 +155,12 @@ mod tests {
use super::*;
#[test]
fn schedule_debounced_postpones_existing_reflow() {
fn schedule_immediate_marks_reflow_due_now() {
let mut state = TranscriptReflowState::default();
assert!(!state.schedule_debounced(/*target_width*/ None));
let first_deadline = state.pending_until().expect("pending reflow");
state.schedule_immediate();
std::thread::sleep(Duration::from_millis(1));
assert!(!state.schedule_debounced(/*target_width*/ None));
assert!(
state.pending_until().expect("pending reflow") > first_deadline,
"a later resize should push the debounce deadline out"
);
}
#[test]
fn schedule_debounced_postpones_due_existing_reflow() {
let mut state = TranscriptReflowState::default();
state.set_due_for_test();
let before_reschedule = Instant::now();
assert!(!state.schedule_debounced(/*target_width*/ None));
assert!(
state.pending_until().expect("pending reflow") > before_reschedule,
"a resize after the old deadline should start a fresh quiet period"
);
assert!(state.pending_is_due(Instant::now()));
}
#[test]
@@ -240,28 +196,6 @@ mod tests {
assert!(state.reflow_needed_for_width(/*width*/ 100));
}
#[test]
fn pending_reflow_target_prevents_repeated_reschedule() {
let mut state = TranscriptReflowState::default();
state.note_width(/*width*/ 80);
assert!(state.reflow_needed_for_width(/*width*/ 100));
state.schedule_debounced(/*target_width*/ Some(100));
assert!(!state.reflow_needed_for_width(/*width*/ 100));
}
#[test]
fn clear_pending_reflow_allows_same_width_to_be_rescheduled() {
let mut state = TranscriptReflowState::default();
state.note_width(/*width*/ 80);
state.schedule_debounced(/*target_width*/ Some(100));
state.clear_pending_reflow();
assert!(state.reflow_needed_for_width(/*width*/ 100));
}
#[test]
fn mark_reflowed_width_reports_unchanged_width() {
let mut state = TranscriptReflowState::default();

View File

@@ -46,6 +46,9 @@ use crate::tui::event_stream::TuiEventStream;
use crate::tui::job_control::SuspendContext;
use codex_config::types::NotificationCondition;
use codex_config::types::NotificationMethod;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
mod event_stream;
mod frame_rate_limiter;
@@ -71,10 +74,35 @@ fn should_emit_notification(condition: NotificationCondition, terminal_focused:
}
}
fn insert_history_mode_for_terminal(
terminal_info: &TerminalInfo,
) -> crate::insert_history::InsertHistoryMode {
let use_newline_insert = matches!(terminal_info.multiplexer, Some(Multiplexer::Zellij {}))
|| matches!(
terminal_info.name,
TerminalName::GnomeTerminal | TerminalName::Vte
);
crate::insert_history::InsertHistoryMode::new(use_newline_insert)
}
#[cfg(test)]
mod tests {
use super::insert_history_mode_for_terminal;
use super::should_emit_notification;
use codex_config::types::NotificationCondition;
use codex_terminal_detection::Multiplexer;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
fn terminal_info(name: TerminalName, multiplexer: Option<Multiplexer>) -> TerminalInfo {
TerminalInfo {
name,
term_program: None,
version: None,
term: None,
multiplexer,
}
}
#[test]
fn unfocused_notification_condition_is_suppressed_when_focused() {
@@ -99,6 +127,46 @@ mod tests {
/*terminal_focused*/ false
));
}
#[test]
fn vte_backed_terminals_use_newline_history_insert() {
assert_eq!(
insert_history_mode_for_terminal(&terminal_info(
TerminalName::Vte,
/*multiplexer*/ None
)),
crate::insert_history::InsertHistoryMode::Newline
);
assert_eq!(
insert_history_mode_for_terminal(&terminal_info(
TerminalName::GnomeTerminal,
/*multiplexer*/ None
)),
crate::insert_history::InsertHistoryMode::Newline
);
}
#[test]
fn zellij_uses_newline_history_insert() {
assert_eq!(
insert_history_mode_for_terminal(&terminal_info(
TerminalName::Unknown,
Some(Multiplexer::Zellij {})
)),
crate::insert_history::InsertHistoryMode::Newline
);
}
#[test]
fn regular_terminals_keep_standard_history_insert() {
assert_eq!(
insert_history_mode_for_terminal(&terminal_info(
TerminalName::Iterm2,
/*multiplexer*/ None
)),
crate::insert_history::InsertHistoryMode::Standard
);
}
}
pub fn set_modes() -> Result<()> {
@@ -316,6 +384,7 @@ pub struct Tui {
event_broker: Arc<EventBroker>,
pub(crate) terminal: Terminal,
pending_history_lines: Vec<Line<'static>>,
pending_resize_replay_lines: Option<Vec<Line<'static>>>,
alt_saved_viewport: Option<ratatui::layout::Rect>,
#[cfg(unix)]
suspend_context: SuspendContext,
@@ -327,6 +396,7 @@ pub struct Tui {
notification_backend: Option<DesktopNotificationBackend>,
notification_condition: NotificationCondition,
is_zellij: bool,
history_insert_mode: crate::insert_history::InsertHistoryMode,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
}
@@ -343,10 +413,9 @@ impl Tui {
// Cache this to avoid contention with the event reader.
supports_color::on_cached(supports_color::Stream::Stdout);
let _ = crate::terminal_palette::default_colors();
let is_zellij = matches!(
codex_terminal_detection::terminal_info().multiplexer,
Some(codex_terminal_detection::Multiplexer::Zellij {})
);
let terminal_info = codex_terminal_detection::terminal_info();
let is_zellij = matches!(terminal_info.multiplexer, Some(Multiplexer::Zellij {}));
let history_insert_mode = insert_history_mode_for_terminal(&terminal_info);
Self {
frame_requester,
@@ -354,6 +423,7 @@ impl Tui {
event_broker: Arc::new(EventBroker::new()),
terminal,
pending_history_lines: vec![],
pending_resize_replay_lines: None,
alt_saved_viewport: None,
#[cfg(unix)]
suspend_context: SuspendContext::new(),
@@ -363,6 +433,7 @@ impl Tui {
notification_backend: Some(detect_backend(NotificationMethod::default())),
notification_condition: NotificationCondition::default(),
is_zellij,
history_insert_mode,
alt_screen_enabled: true,
}
}
@@ -532,8 +603,15 @@ impl Tui {
self.frame_requester().schedule_frame();
}
pub(crate) fn replay_history_lines_after_resize(&mut self, lines: Vec<Line<'static>>) {
self.pending_history_lines.clear();
self.pending_resize_replay_lines = Some(lines);
self.frame_requester().schedule_frame();
}
pub fn clear_pending_history_lines(&mut self) {
self.pending_history_lines.clear();
self.pending_resize_replay_lines = None;
}
/// Resize the inline viewport to `height` rows, scrolling content above it if
@@ -630,7 +708,16 @@ impl Tui {
}
if area != terminal.viewport_area {
let clear_position = Position::new(/*x*/ 0, previous_area.y.min(area.y));
let terminal_resized = size != terminal.last_known_screen_size;
// Terminal emulators can natively reflow visible scrollback before Codex gets the
// resize event. When that happens, ratatui's diff buffer has no record of the glyphs
// now sitting under blank cells, so repaint from a fully-cleared visible screen.
let clear_y = if terminal_resized {
0
} else {
previous_area.y.min(area.y)
};
let clear_position = Position::new(/*x*/ 0, clear_y);
terminal.set_viewport_area(area);
terminal.clear_after_position(clear_position)?;
needs_full_repaint = true;
@@ -645,7 +732,7 @@ impl Tui {
fn flush_pending_history_lines(
terminal: &mut Terminal,
pending_history_lines: &mut Vec<Line<'static>>,
is_zellij: bool,
history_insert_mode: crate::insert_history::InsertHistoryMode,
) -> Result<bool> {
if pending_history_lines.is_empty() {
return Ok(false);
@@ -654,10 +741,29 @@ impl Tui {
crate::insert_history::insert_history_lines_with_mode(
terminal,
pending_history_lines.clone(),
crate::insert_history::InsertHistoryMode::new(is_zellij),
history_insert_mode,
)?;
pending_history_lines.clear();
Ok(is_zellij)
Ok(history_insert_mode == crate::insert_history::InsertHistoryMode::Newline)
}
fn flush_pending_resize_replay(
terminal: &mut Terminal,
pending_resize_replay_lines: &mut Option<Vec<Line<'static>>>,
alt_screen_active: bool,
) -> Result<bool> {
let Some(lines) = pending_resize_replay_lines.take() else {
return Ok(false);
};
if alt_screen_active {
terminal.clear_visible_screen()?;
} else {
terminal.clear_scrollback_and_visible_screen_ansi()?;
}
crate::insert_history::replay_history_lines_after_clear(terminal, lines)?;
Ok(true)
}
pub fn draw(
@@ -693,7 +799,7 @@ impl Tui {
needs_full_repaint |= Self::flush_pending_history_lines(
terminal,
&mut self.pending_history_lines,
self.is_zellij,
self.history_insert_mode,
)?;
if needs_full_repaint {
@@ -746,12 +852,21 @@ impl Tui {
let terminal = &mut self.terminal;
let mut needs_full_repaint =
Self::update_inline_viewport_for_resize_reflow(terminal, height, self.is_zellij)?;
let flushed_history = Self::flush_pending_history_lines(
let replayed_history = Self::flush_pending_resize_replay(
terminal,
&mut self.pending_history_lines,
self.is_zellij,
&mut self.pending_resize_replay_lines,
self.alt_screen_active.load(Ordering::Relaxed),
)?;
needs_full_repaint |= flushed_history;
if replayed_history {
self.pending_history_lines.clear();
} else {
needs_full_repaint |= Self::flush_pending_history_lines(
terminal,
&mut self.pending_history_lines,
self.history_insert_mode,
)?;
}
needs_full_repaint |= replayed_history;
if needs_full_repaint {
terminal.invalidate_viewport();