mirror of
https://github.com/openai/codex.git
synced 2026-05-06 04:17:03 +00:00
Compare commits
20 Commits
pakrym/res
...
fcoury/md-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
887947d7a8 | ||
|
|
47fafe6836 | ||
|
|
25c7f8e0e8 | ||
|
|
1db80892bc | ||
|
|
6cc4173121 | ||
|
|
c32994513b | ||
|
|
280d5841df | ||
|
|
916e73853d | ||
|
|
a60ad245a3 | ||
|
|
fc40b0394e | ||
|
|
939a9ffd37 | ||
|
|
a0b16c1e1e | ||
|
|
5a645f40dd | ||
|
|
29458259c0 | ||
|
|
48d8256006 | ||
|
|
2f948bac03 | ||
|
|
284a928aa8 | ||
|
|
e73760e90c | ||
|
|
dd00158caa | ||
|
|
927c909004 |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
947
codex-rs/tui/src/markdown_render/table.rs
Normal file
947
codex-rs/tui/src/markdown_render/table.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
77
codex-rs/tui/src/markdown_render/table_cell.rs
Normal file
77
codex-rs/tui/src/markdown_render/table_cell.rs
Normal 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>()
|
||||
}
|
||||
61
codex-rs/tui/src/markdown_render/table_state.rs
Normal file
61
codex-rs/tui/src/markdown_render/table_state.rs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 │
|
||||
└──────────┴──────────────┴──────────────────────────┴─────────────────────┘
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
141
codex-rs/tui/src/streaming/source_partition.rs
Normal file
141
codex-rs/tui/src/streaming/source_partition.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user