Compare commits

...

2 Commits

Author SHA1 Message Date
canvrno-oai
2375c721a3 testing 2026-04-08 15:29:22 -07:00
canvrno-oai
bbf2554b98 Fix output width issue 2026-04-08 12:30:30 -07:00
7 changed files with 173 additions and 49 deletions

View File

@@ -3985,10 +3985,7 @@ impl App {
event: TuiEvent,
) -> Result<AppRunControl> {
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!(

View File

@@ -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

View File

@@ -32,23 +32,29 @@ use ratatui::text::Span;
/// 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 terminal
/// stacks either mishandle those sequences or do not promote content that scrolls
/// out of a bounded scroll region into native scrollback; `BottomNewline` 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,
BottomNewline,
}
impl InsertHistoryMode {
pub fn new(is_zellij: bool) -> Self {
if is_zellij {
Self::Zellij
pub fn from_bottom_newline_preference(prefers_bottom_newline: bool) -> Self {
if prefers_bottom_newline {
Self::BottomNewline
} else {
Self::Standard
}
}
pub fn requires_full_repaint(self) -> bool {
matches!(self, Self::BottomNewline)
}
}
/// Insert `lines` above the viewport using the terminal's backend writer
@@ -66,9 +72,9 @@ 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
/// scrollback down and writes new lines into the freed space. In `BottomNewline`
/// mode it emits newlines at the screen bottom to create space and writes lines
/// at computed absolute positions. Both modes
/// update `terminal.viewport_area` so subsequent draw passes know where the
/// viewport moved to.
pub fn insert_history_lines_with_mode<B>(
@@ -116,7 +122,7 @@ where
}
let wrapped_lines = wrapped_rows as u16;
if matches!(mode, InsertHistoryMode::Zellij) {
if matches!(mode, InsertHistoryMode::BottomNewline) {
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);
@@ -801,7 +807,7 @@ mod tests {
}
#[test]
fn vt100_zellij_mode_inserts_history_and_updates_viewport() {
fn vt100_bottom_newline_mode_inserts_history_and_updates_viewport() {
let width: u16 = 32;
let height: u16 = 8;
let backend = VT100Backend::new(width, height);
@@ -810,7 +816,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::BottomNewline)
.expect("insert zellij history");
let rows: Vec<String> = term.backend().vt100().screen().rows(0, width).collect();

View File

@@ -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<usize>,
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<usize>) {
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<Line<'static>> {
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<Line<'static>> = 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<Line<'static>> = 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);

View File

@@ -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<usize>) {
self.state.set_width(width);
}
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
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<usize>) {
self.state.set_width(width);
}
fn emit(
&mut self,
lines: Vec<Line<'static>>,

View File

@@ -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<usize>) {
self.collector.set_width(width);
}
/// Drains one queued line from the front of the queue.
pub(crate) fn step(&mut self) -> Vec<Line<'static>> {
self.queued_lines

View File

@@ -40,6 +40,7 @@ use tokio_stream::Stream;
pub use self::frame_requester::FrameRequester;
use crate::custom_terminal;
use crate::custom_terminal::Terminal as CustomTerminal;
use crate::insert_history::InsertHistoryMode;
use crate::notifications::DesktopNotificationBackend;
use crate::notifications::detect_backend;
use crate::tui::event_stream::EventBroker;
@@ -47,6 +48,8 @@ use crate::tui::event_stream::TuiEventStream;
#[cfg(unix)]
use crate::tui::job_control::SuspendContext;
use codex_config::types::NotificationMethod;
use codex_terminal_detection::TerminalInfo;
use codex_terminal_detection::TerminalName;
mod event_stream;
mod frame_rate_limiter;
@@ -255,10 +258,25 @@ pub struct Tui {
enhanced_keys_supported: bool,
notification_backend: Option<DesktopNotificationBackend>,
is_zellij: bool,
history_insertion_mode: InsertHistoryMode,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
}
fn history_insertion_mode_for_terminal(terminal_info: &TerminalInfo) -> InsertHistoryMode {
InsertHistoryMode::from_bottom_newline_preference(
terminal_info.is_zellij()
|| terminal_prefers_bottom_newline_history_insertion(terminal_info.name),
)
}
fn terminal_prefers_bottom_newline_history_insertion(terminal_name: TerminalName) -> bool {
matches!(
terminal_name,
TerminalName::VsCode | TerminalName::WindowsTerminal
)
}
impl Tui {
pub fn new(terminal: Terminal) -> Self {
let (draw_tx, _) = broadcast::channel(1);
@@ -270,10 +288,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 = terminal_info.is_zellij();
let history_insertion_mode = history_insertion_mode_for_terminal(&terminal_info);
Self {
frame_requester,
@@ -289,6 +306,7 @@ impl Tui {
enhanced_keys_supported,
notification_backend: Some(detect_backend(NotificationMethod::default())),
is_zellij,
history_insertion_mode,
alt_screen_enabled: true,
}
}
@@ -512,12 +530,13 @@ impl Tui {
}
/// Write any buffered history lines above the viewport and clear the buffer.
/// Returns `true` when Zellij mode was used, signaling that the caller must
/// invalidate the diff buffer for a full repaint.
/// Returns `true` when the selected insertion mode moved screen content
/// outside ratatui's diff model, signaling that the caller must invalidate
/// the diff buffer for a full repaint.
fn flush_pending_history_lines(
terminal: &mut Terminal,
pending_history_lines: &mut Vec<Line<'static>>,
is_zellij: bool,
history_insertion_mode: InsertHistoryMode,
) -> Result<bool> {
if pending_history_lines.is_empty() {
return Ok(false);
@@ -526,10 +545,10 @@ impl Tui {
crate::insert_history::insert_history_lines_with_mode(
terminal,
pending_history_lines.clone(),
crate::insert_history::InsertHistoryMode::new(is_zellij),
history_insertion_mode,
)?;
pending_history_lines.clear();
Ok(is_zellij)
Ok(history_insertion_mode.requires_full_repaint())
}
pub fn draw(
@@ -565,7 +584,7 @@ impl Tui {
needs_full_repaint |= Self::flush_pending_history_lines(
terminal,
&mut self.pending_history_lines,
self.is_zellij,
self.history_insertion_mode,
)?;
if needs_full_repaint {
@@ -614,3 +633,25 @@ impl Tui {
Ok(None)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bottom_newline_history_insertion_is_used_for_known_scrollback_problem_terminals() {
assert!(terminal_prefers_bottom_newline_history_insertion(
TerminalName::VsCode
));
assert!(terminal_prefers_bottom_newline_history_insertion(
TerminalName::WindowsTerminal
));
assert!(!terminal_prefers_bottom_newline_history_insertion(
TerminalName::Iterm2
));
assert!(!terminal_prefers_bottom_newline_history_insertion(
TerminalName::Unknown
));
}
}