From bbf2554b98e6731515f2cb80f1bde67777307a1a Mon Sep 17 00:00:00 2001 From: canvrno-oai Date: Wed, 8 Apr 2026 12:30:30 -0700 Subject: [PATCH] Fix output width issue --- codex-rs/tui/src/app.rs | 20 ++++-- codex-rs/tui/src/chatwidget.rs | 10 +++ codex-rs/tui/src/markdown_stream.rs | 85 ++++++++++++++++++------ codex-rs/tui/src/streaming/controller.rs | 10 +++ codex-rs/tui/src/streaming/mod.rs | 4 ++ 5 files changed, 103 insertions(+), 26 deletions(-) diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 75dca6afca..be928b1942 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -3985,10 +3985,7 @@ impl App { event: TuiEvent, ) -> Result { if matches!(event, TuiEvent::Draw) { - let size = tui.terminal.size()?; - if size != tui.terminal.last_known_screen_size { - self.refresh_status_line(); - } + self.sync_terminal_size_dependent_state(tui)?; } if self.overlay.is_some() { @@ -4040,6 +4037,15 @@ impl App { Ok(AppRunControl::Continue) } + fn sync_terminal_size_dependent_state(&mut self, tui: &mut tui::Tui) -> Result<()> { + let size = tui.terminal.size()?; + if size != tui.terminal.last_known_screen_size { + self.chat_widget.update_stream_render_width(size.width); + self.refresh_status_line(); + } + Ok(()) + } + async fn handle_event( &mut self, tui: &mut tui::Tui, @@ -4256,7 +4262,8 @@ impl App { tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); - let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); + let display_width = tui.terminal.size()?.width; + let mut display = cell.display_lines(display_width); if !display.is_empty() { // Only insert a separating blank line for new cells that are not // part of an ongoing stream. Streaming continuations should not @@ -4300,6 +4307,7 @@ impl App { self.commit_anim_running.store(false, Ordering::Release); } AppEvent::CommitTick => { + self.sync_terminal_size_dependent_state(tui)?; self.chat_widget.on_commit_tick(); } AppEvent::Exit(mode) => { @@ -5709,6 +5717,8 @@ impl App { app_server: &mut AppServerSession, event: ThreadBufferedEvent, ) -> Result<()> { + self.sync_terminal_size_dependent_state(tui)?; + // Capture this before any potential thread switch: we only want to clear // the exit marker when the currently active thread acknowledges shutdown. let pending_shutdown_exit_completed = matches!( diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index bc267383db..17d70539ad 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -4026,6 +4026,16 @@ impl ChatWidget { } } + pub(crate) fn update_stream_render_width(&mut self, terminal_width: u16) { + let terminal_width = usize::from(terminal_width); + if let Some(controller) = self.stream_controller.as_mut() { + controller.set_width(Some(terminal_width.saturating_sub(2))); + } + if let Some(controller) = self.plan_stream_controller.as_mut() { + controller.set_width(Some(terminal_width.saturating_sub(4))); + } + } + /// Handle completion of an `AgentMessage` turn item. /// /// Commentary completion sets a deferred restore flag so the status row diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 3eb37e345e..2e4abc7fd0 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -3,12 +3,14 @@ use std::path::Path; use std::path::PathBuf; use crate::markdown; +use crate::render::line_utils::is_blank_line_spaces_only; /// Newline-gated accumulator that renders markdown and commits only fully /// completed logical lines. pub(crate) struct MarkdownStreamCollector { buffer: String, committed_line_count: usize, + committed_source_len: usize, width: Option, cwd: PathBuf, } @@ -24,6 +26,7 @@ impl MarkdownStreamCollector { Self { buffer: String::new(), committed_line_count: 0, + committed_source_len: 0, width, cwd: cwd.to_path_buf(), } @@ -32,6 +35,18 @@ impl MarkdownStreamCollector { pub fn clear(&mut self) { self.buffer.clear(); self.committed_line_count = 0; + self.committed_source_len = 0; + } + + pub fn set_width(&mut self, width: Option) { + if self.width == width { + return; + } + + self.width = width; + // The rendered line index is width-dependent; preserve the source commit boundary and + // recompute how many rendered lines it occupies at the new width. + self.committed_line_count = self.rendered_committed_line_count(); } pub fn push_delta(&mut self, delta: &str) { @@ -43,35 +58,37 @@ impl MarkdownStreamCollector { /// since the last commit. When the buffer does not end with a newline, the /// final rendered line is considered incomplete and is not emitted. pub fn commit_complete_lines(&mut self) -> Vec> { - let source = self.buffer.clone(); - let last_newline_idx = source.rfind('\n'); - let source = if let Some(last_newline_idx) = last_newline_idx { - source[..=last_newline_idx].to_string() - } else { + let Some(last_newline_idx) = self.buffer.rfind('\n') else { return Vec::new(); }; + let commit_source_len = last_newline_idx + 1; + let source = &self.buffer[..commit_source_len]; let mut rendered: Vec> = Vec::new(); - markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered); - let mut complete_line_count = rendered.len(); - if complete_line_count > 0 - && crate::render::line_utils::is_blank_line_spaces_only( - &rendered[complete_line_count - 1], - ) - { - complete_line_count -= 1; - } + markdown::append_markdown(source, self.width, Some(self.cwd.as_path()), &mut rendered); + let complete_line_count = complete_line_count(&rendered); - if self.committed_line_count >= complete_line_count { - return Vec::new(); - } + let out = if self.committed_line_count >= complete_line_count { + Vec::new() + } else { + rendered[self.committed_line_count..complete_line_count].to_vec() + }; - let out_slice = &rendered[self.committed_line_count..complete_line_count]; - - let out = out_slice.to_vec(); - self.committed_line_count = complete_line_count; + self.committed_line_count = complete_line_count.max(self.committed_line_count); + self.committed_source_len = commit_source_len; out } + fn rendered_committed_line_count(&self) -> usize { + if self.committed_source_len == 0 { + return 0; + } + + let source = &self.buffer[..self.committed_source_len]; + let mut rendered: Vec> = Vec::new(); + markdown::append_markdown(source, self.width, Some(self.cwd.as_path()), &mut rendered); + complete_line_count(&rendered) + } + /// Finalize the stream: emit all remaining lines beyond the last commit. /// If the buffer does not end with a newline, a temporary one is appended /// for rendering. Optionally unwraps ```markdown language fences in @@ -106,6 +123,14 @@ impl MarkdownStreamCollector { } } +fn complete_line_count(rendered: &[Line<'static>]) -> usize { + let mut complete_line_count = rendered.len(); + if complete_line_count > 0 && is_blank_line_spaces_only(&rendered[complete_line_count - 1]) { + complete_line_count -= 1; + } + complete_line_count +} + #[cfg(test)] fn test_cwd() -> PathBuf { // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or @@ -156,6 +181,24 @@ mod tests { assert_eq!(out.len(), 1); } + #[tokio::test] + async fn changing_width_preserves_source_commit_position() { + let mut c = super::MarkdownStreamCollector::new(Some(16), &super::test_cwd()); + c.push_delta("- first second third fourth\n"); + + let first = c.commit_complete_lines(); + assert_eq!( + lines_to_plain_strings(&first), + vec!["- first second", " third fourth"] + ); + + c.set_width(Some(40)); + c.push_delta("- fifth sixth\n"); + + let next = c.commit_complete_lines(); + assert_eq!(lines_to_plain_strings(&next), vec!["- fifth sixth"]); + } + #[tokio::test] async fn e2e_stream_blockquote_simple_is_green() { let out = super::simulate_stream_markdown_for_tests(&["> Hello\n"], /*finalize*/ true); diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index d524e707a6..9eb131664b 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -100,6 +100,11 @@ impl StreamController { self.state.oldest_queued_age(now) } + /// Updates the markdown render width used for future stream commits. + pub(crate) fn set_width(&mut self, width: Option) { + self.state.set_width(width); + } + fn emit(&mut self, lines: Vec>) -> Option> { if lines.is_empty() { return None; @@ -204,6 +209,11 @@ impl PlanStreamController { self.state.oldest_queued_age(now) } + /// Updates the markdown render width used for future stream commits. + pub(crate) fn set_width(&mut self, width: Option) { + self.state.set_width(width); + } + fn emit( &mut self, lines: Vec>, diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index ae3b68742a..3050487408 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -51,6 +51,10 @@ impl StreamState { self.queued_lines.clear(); self.has_seen_delta = false; } + /// Updates the markdown render width used for future stream commits. + pub(crate) fn set_width(&mut self, width: Option) { + self.collector.set_width(width); + } /// Drains one queued line from the front of the queue. pub(crate) fn step(&mut self) -> Vec> { self.queued_lines