From 0bd31dc382bd1c33dc2bb6b97069c76aa10ba14b Mon Sep 17 00:00:00 2001 From: fcoury-oai Date: Thu, 2 Apr 2026 18:07:05 -0300 Subject: [PATCH] fix(tui): handle zellij redraw and composer rendering (#16578) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## TL;DR Fixes the issues when using Codex CLI with Zellij multiplexer. Before this PR there would be no scrollback when using it inside a zellij terminal. ## Problem Addresses #2558 Zellij does not support ANSI scroll-region manipulation (`DECSTBM` / Reverse Index) or the alternate screen buffer in the way traditional terminals do. When codex's TUI runs inside Zellij, two things break: (1) inline history insertion corrupts the display because the scroll-region escape sequences are silently dropped or mishandled, and (2) the composer textarea renders with inherited background/foreground styles that produce unreadable text against Zellij's pane chrome. ## Mental model The fix introduces a **Zellij mode** — a runtime boolean detected once at startup via `codex_terminal_detection::terminal_info().is_zellij()` — that gates two subsystems onto Zellij-safe terminal strategies: - **History insertion** (`insert_history.rs`): Instead of using `DECSTBM` scroll regions and Reverse Index (`ESC M`) to slide content above the viewport, Zellij mode scrolls the screen by emitting `\n` at the bottom row and then writes history lines at absolute positions. This avoids every escape sequence Zellij mishandles. - **Viewport expansion** (`tui.rs`): When the viewport grows taller than available space, the standard path uses `scroll_region_up` on the backend. Zellij mode instead emits newlines at the screen bottom to push content up, then invalidates the ratatui diff buffer so the next draw is a full repaint. - **Composer rendering** (`chat_composer.rs`, `textarea.rs`): All text rendering in the input area uses an explicit `base_style` with `Color::Reset` foreground, preventing Zellij's pane styling from bleeding into the textarea. The prompt chevron (`›`) and placeholder text use explicit color constants instead of relying on `.bold()` / `.dim()` modifiers that render inconsistently under Zellij. ## Non-goals - This change does not fix or improve Zellij's terminal emulation itself. - It does not rearchitect the inline viewport model; it adds a parallel code path gated on detection. - It does not touch the alternate-screen disable logic (that already existed and continues to use `is_zellij` via the same detection). ## Tradeoffs - **Code duplication in `insert_history.rs`**: The Zellij and Standard branches share the line-rendering loop (color setup, span merging, `write_spans`) but differ in the scrolling preamble. The duplication is intentional — merging them would force a complex conditional state machine that's harder to reason about than two flat sequences. - **`invalidate_viewport` after every Zellij history flush or viewport expansion**: This forces a full repaint on every draw cycle in Zellij, which is more expensive than ratatui's normal diff-based rendering. This is necessary because Zellij's lack of scroll-region support means the diff buffer's assumptions about what's on screen are invalid after we manually move content. - **Explicit colors vs semantic modifiers**: Replacing `.bold()` / `.dim()` with `Color::Cyan` / `Color::DarkGray` / `Color::White` in the Zellij branch sacrifices theme-awareness for correctness. If the project ever adopts a theming system, Zellij styling will need to participate. ## Architecture The Zellij detection flag flows through three layers: 1. **`codex_terminal_detection`** — `TerminalInfo::is_zellij()` (new convenience method) reads the already-detected `Multiplexer` variant. 2. **`Tui` struct** — caches `is_zellij` at construction; passes it into `update_inline_viewport`, `flush_pending_history_lines`, and `insert_history_lines_with_mode`. 3. **`ChatComposer` struct** — independently caches `is_zellij` at construction; uses it in `render_textarea` for style decisions. The two caches (`Tui.is_zellij` and `ChatComposer.is_zellij`) are read from the same global `OnceLock`, so they always agree. ## Observability No new logging, metrics, or tracing is introduced. Diagnosis depends on: - Whether `ZELLIJ` or `ZELLIJ_SESSION_NAME` env vars are set (the detection heuristic). - Visual inspection of the rendered TUI inside Zellij vs a standard terminal. - The insta snapshot `zellij_empty_composer` captures the Zellij-mode render path. ## Tests - `terminal_info_reports_is_zellij` — unit test in `terminal-detection` confirming the convenience method. - `zellij_empty_composer_snapshot` — insta snapshot in `chat_composer` validating the Zellij render path for an empty composer. - `vt100_zellij_mode_inserts_history_and_updates_viewport` — integration test in `insert_history` verifying that Zellij-mode history insertion writes content and shifts the viewport. --------- Co-authored-by: Claude Opus 4.6 (1M context) --- codex-rs/terminal-detection/src/lib.rs | 5 + .../terminal-detection/src/terminal_tests.rs | 21 ++ codex-rs/tui/src/bottom_pane/chat_composer.rs | 106 ++++++- ...omposer__tests__zellij_empty_composer.snap | 14 + codex-rs/tui/src/bottom_pane/textarea.rs | 38 ++- codex-rs/tui/src/custom_terminal.rs | 8 + codex-rs/tui/src/insert_history.rs | 262 ++++++++++++------ codex-rs/tui/src/lib.rs | 3 +- codex-rs/tui/src/tui.rs | 112 ++++++-- 9 files changed, 444 insertions(+), 125 deletions(-) create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__zellij_empty_composer.snap diff --git a/codex-rs/terminal-detection/src/lib.rs b/codex-rs/terminal-detection/src/lib.rs index c233c7283..69b6f4745 100644 --- a/codex-rs/terminal-detection/src/lib.rs +++ b/codex-rs/terminal-detection/src/lib.rs @@ -204,6 +204,11 @@ impl TerminalInfo { sanitize_header_value(raw) } + + /// Returns whether the active terminal multiplexer is Zellij. + pub fn is_zellij(&self) -> bool { + matches!(self.multiplexer, Some(Multiplexer::Zellij {})) + } } static TERMINAL_INFO: OnceLock = OnceLock::new(); diff --git a/codex-rs/terminal-detection/src/terminal_tests.rs b/codex-rs/terminal-detection/src/terminal_tests.rs index bd527bc55..52974a0c3 100644 --- a/codex-rs/terminal-detection/src/terminal_tests.rs +++ b/codex-rs/terminal-detection/src/terminal_tests.rs @@ -122,6 +122,27 @@ fn detects_term_program() { ); } +#[test] +fn terminal_info_reports_is_zellij() { + let zellij = terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Zellij {}), + ); + assert!(zellij.is_zellij()); + + let non_zellij = terminal_info( + TerminalName::Unknown, + /*term_program*/ None, + /*version*/ None, + /*term*/ None, + Some(Multiplexer::Tmux { version: None }), + ); + assert!(!non_zellij.is_zellij()); +} + #[test] fn detects_iterm2() { let env = FakeEnvironment::new().with_var("ITERM_SESSION_ID", "w0t1p0"); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 22da977e7..6cf6c18d3 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -333,6 +333,7 @@ pub(crate) struct ChatComposer { realtime_conversation_enabled: bool, audio_device_selection_enabled: bool, windows_degraded_sandbox_active: bool, + is_zellij: bool, status_line_value: Option>, status_line_enabled: bool, // Agent label injected into the footer's contextual row when multi-agent mode is active. @@ -454,6 +455,7 @@ impl ChatComposer { realtime_conversation_enabled: false, audio_device_selection_enabled: false, windows_degraded_sandbox_active: false, + is_zellij: codex_terminal_detection::terminal_info().is_zellij(), status_line_value: None, status_line_enabled: false, active_agent_label: None, @@ -3672,16 +3674,50 @@ impl ChatComposer { } } } + self.render_textarea( + composer_rect, + remote_images_rect, + textarea_rect, + buf, + mask_char, + ); + } + + /// Paint the composer's text input area, prompt chevron, and placeholder text. + /// + /// In Zellij sessions the textarea uses explicit `Color::Reset` foreground styling + /// to prevent the multiplexer's pane chrome from bleeding into cell styles, and + /// substitutes hardcoded colors for `.bold()` / `.dim()` modifiers that Zellij + /// renders inconsistently. The standard path is unchanged. + fn render_textarea( + &self, + composer_rect: Rect, + remote_images_rect: Rect, + textarea_rect: Rect, + buf: &mut Buffer, + mask_char: Option, + ) { + let is_zellij = self.is_zellij; let style = user_message_style(); + let textarea_style = style.fg(ratatui::style::Color::Reset); Block::default().style(style).render_ref(composer_rect, buf); if !remote_images_rect.is_empty() { Paragraph::new(self.remote_images_lines(remote_images_rect.width)) .style(style) .render_ref(remote_images_rect, buf); } + if is_zellij && !textarea_rect.is_empty() { + buf.set_style(textarea_rect, textarea_style); + } if !textarea_rect.is_empty() { let prompt = if self.input_enabled { - "›".bold() + if is_zellij { + Span::styled("›", style.fg(ratatui::style::Color::Cyan)) + } else { + "›".bold() + } + } else if is_zellij { + Span::styled("›", style.fg(ratatui::style::Color::DarkGray)) } else { "›".dim() }; @@ -3694,13 +3730,28 @@ impl ChatComposer { } let mut state = self.textarea_state.borrow_mut(); + let textarea_is_empty = self.textarea.text().is_empty(); if let Some(mask_char) = mask_char { + self.textarea.render_ref_masked( + textarea_rect, + buf, + &mut state, + mask_char, + if is_zellij { + textarea_style + } else { + ratatui::style::Style::default() + }, + ); + } else if is_zellij && textarea_is_empty { + buf.set_style(textarea_rect, textarea_style); + } else if is_zellij { self.textarea - .render_ref_masked(textarea_rect, buf, &mut state, mask_char); + .render_ref_styled(textarea_rect, buf, &mut state, textarea_style); } else { StatefulWidgetRef::render_ref(&(&self.textarea), textarea_rect, buf, &mut state); } - if self.textarea.text().is_empty() { + if textarea_is_empty { let text = if self.input_enabled { self.placeholder_text.as_str().to_string() } else { @@ -3710,9 +3761,18 @@ impl ChatComposer { .to_string() }; if !textarea_rect.is_empty() { - let placeholder = Span::from(text).dim(); - Line::from(vec![placeholder]) - .render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); + if is_zellij { + buf.set_string( + textarea_rect.x, + textarea_rect.y, + text, + textarea_style.fg(ratatui::style::Color::White).italic(), + ); + } else { + let placeholder = Span::from(text).dim(); + let line = Line::from(vec![placeholder]); + line.render_ref(textarea_rect.inner(Margin::new(0, 0)), buf); + } } } } @@ -3933,6 +3993,35 @@ mod tests { ); } + fn snapshot_zellij_composer_state(name: &str, setup: F) + where + F: FnOnce(&mut ChatComposer), + { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ true, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + composer.is_zellij = true; + setup(&mut composer); + let footer_props = composer.footer_props(); + let footer_lines = footer_height(&footer_props); + let footer_spacing = ChatComposer::footer_spacing(footer_lines); + let height = footer_lines + footer_spacing + 8; + let mut terminal = Terminal::new(TestBackend::new(100, height)).unwrap(); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .unwrap(); + insta::assert_snapshot!(name, terminal.backend()); + } + #[test] fn footer_mode_snapshots() { use crossterm::event::KeyCode; @@ -4261,6 +4350,11 @@ mod tests { ); } + #[test] + fn zellij_empty_composer_snapshot() { + snapshot_zellij_composer_state("zellij_empty_composer", |_composer| {}); + } + #[test] fn esc_hint_stays_hidden_with_draft_content() { use crossterm::event::KeyCode; diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__zellij_empty_composer.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__zellij_empty_composer.snap new file mode 100644 index 000000000..15496a453 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__zellij_empty_composer.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +assertion_line: 4001 +expression: terminal.backend() +--- +" " +"› Ask Codex to do anything " +" " +" " +" " +" " +" " +" " +" ? for shortcuts 100% context left " diff --git a/codex-rs/tui/src/bottom_pane/textarea.rs b/codex-rs/tui/src/bottom_pane/textarea.rs index f6d0dda04..d745d2ecd 100644 --- a/codex-rs/tui/src/bottom_pane/textarea.rs +++ b/codex-rs/tui/src/bottom_pane/textarea.rs @@ -19,7 +19,6 @@ use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::buffer::Buffer; use ratatui::layout::Rect; -use ratatui::style::Color; use ratatui::style::Style; use ratatui::widgets::StatefulWidgetRef; use ratatui::widgets::WidgetRef; @@ -1322,7 +1321,7 @@ impl TextArea { impl WidgetRef for &TextArea { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let lines = self.wrapped_lines(area.width); - self.render_lines(area, buf, &lines, 0..lines.len()); + self.render_lines(area, buf, &lines, 0..lines.len(), Style::default()); } } @@ -1336,7 +1335,7 @@ impl StatefulWidgetRef for &TextArea { let start = scroll as usize; let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines(area, buf, &lines, start..end); + self.render_lines(area, buf, &lines, start..end, Style::default()); } } @@ -1347,6 +1346,7 @@ impl TextArea { buf: &mut Buffer, state: &mut TextAreaState, mask_char: char, + base_style: Style, ) { let lines = self.wrapped_lines(area.width); let scroll = self.effective_scroll(area.height, &lines, state.scroll); @@ -1354,7 +1354,25 @@ impl TextArea { let start = scroll as usize; let end = (scroll + area.height).min(lines.len() as u16) as usize; - self.render_lines_masked(area, buf, &lines, start..end, mask_char); + self.render_lines_masked(area, buf, &lines, start..end, mask_char, base_style); + } + + /// Render the textarea with an explicit `base_style` applied to every cell, + /// used by the Zellij code path to override inherited terminal styles. + pub(crate) fn render_ref_styled( + &self, + area: Rect, + buf: &mut Buffer, + state: &mut TextAreaState, + base_style: Style, + ) { + let lines = self.wrapped_lines(area.width); + let scroll = self.effective_scroll(area.height, &lines, state.scroll); + state.scroll = scroll; + + let start = scroll as usize; + let end = (scroll + area.height).min(lines.len() as u16) as usize; + self.render_lines(area, buf, &lines, start..end, base_style); } fn render_lines( @@ -1363,13 +1381,15 @@ impl TextArea { buf: &mut Buffer, lines: &[Range], range: std::ops::Range, + base_style: Style, ) { for (row, idx) in range.enumerate() { let r = &lines[idx]; let y = area.y + row as u16; let line_range = r.start..r.end - 1; - // Draw base line with default style. - buf.set_string(area.x, y, &self.text[line_range.clone()], Style::default()); + buf.set_style(Rect::new(area.x, y, area.width, 1), base_style); + // Draw base line with the provided style. + buf.set_string(area.x, y, &self.text[line_range.clone()], base_style); // Overlay styled segments for elements that intersect this line. for elem in &self.elements { @@ -1381,7 +1401,7 @@ impl TextArea { } let styled = &self.text[overlap_start..overlap_end]; let x_off = self.text[line_range.start..overlap_start].width() as u16; - let style = Style::default().fg(Color::Cyan); + let style = base_style.fg(ratatui::style::Color::Cyan); buf.set_string(area.x + x_off, y, styled, style); } } @@ -1394,16 +1414,18 @@ impl TextArea { lines: &[Range], range: std::ops::Range, mask_char: char, + base_style: Style, ) { for (row, idx) in range.enumerate() { let r = &lines[idx]; let y = area.y + row as u16; let line_range = r.start..r.end - 1; + buf.set_style(Rect::new(area.x, y, area.width, 1), base_style); let masked = self.text[line_range.clone()] .chars() .map(|_| mask_char) .collect::(); - buf.set_string(area.x, y, &masked, Style::default()); + buf.set_string(area.x, y, &masked, base_style); } } } diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index c51cc726b..556992b80 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -424,6 +424,14 @@ where Ok(()) } + /// Force the next draw pass to repaint the entire viewport by resetting the + /// diff buffer. Call this after operations that move screen content outside of + /// ratatui's knowledge (e.g., Zellij-mode scrolling via raw newlines), since + /// the diff buffer's assumptions about what is currently displayed are invalid. + pub fn invalidate_viewport(&mut self) { + self.previous_buffer_mut().reset(); + } + /// Clear terminal scrollback (if supported) and force a full redraw. pub fn clear_scrollback(&mut self) -> io::Result<()> { if self.viewport_area.is_empty() { diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index e972627bc..76cd699e8 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -29,12 +29,53 @@ use ratatui::style::Modifier; use ratatui::text::Line; 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. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InsertHistoryMode { + Standard, + Zellij, +} + +impl InsertHistoryMode { + pub fn new(is_zellij: bool) -> Self { + if is_zellij { + Self::Zellij + } else { + Self::Standard + } + } +} + /// Insert `lines` above the viewport using the terminal's backend writer /// (avoids direct stdout references). pub fn insert_history_lines( terminal: &mut crate::custom_terminal::Terminal, lines: Vec, ) -> io::Result<()> +where + B: Backend + Write, +{ + insert_history_lines_with_mode(terminal, lines, InsertHistoryMode::Standard) +} + +/// 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. +pub fn insert_history_lines_with_mode( + terminal: &mut crate::custom_terminal::Terminal, + lines: Vec, + mode: InsertHistoryMode, +) -> io::Result<()> where B: Backend + Write, { @@ -74,98 +115,83 @@ where wrapped.extend(line_wrapped); } let wrapped_lines = wrapped_rows as u16; - let cursor_top = if area.bottom() < screen_size.height { - // If the viewport is not at the bottom of the screen, scroll it down to make room. - // Don't scroll it past the bottom of the screen. - let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); - // Emit ANSI to scroll the lower region (from the top of the viewport to the bottom - // of the screen) downward by `scroll_amount` lines. We do this by: - // 1) Limiting the scroll region to [area.top()+1 .. screen_height] (1-based bounds) - // 2) Placing the cursor at the top margin of that region - // 3) Emitting Reverse Index (RI, ESC M) `scroll_amount` times - // 4) Resetting the scroll region back to full screen - let top_1based = area.top() + 1; // Convert 0-based row to 1-based for DECSTBM - queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; - queue!(writer, MoveTo(0, area.top()))?; - for _ in 0..scroll_amount { - // Reverse Index (RI): ESC M - queue!(writer, Print("\x1bM"))?; - } - queue!(writer, ResetScrollRegion)?; + if matches!(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); - let cursor_top = area.top().saturating_sub(1); - area.y += scroll_amount; - should_update_area = true; - cursor_top - } else { - area.top().saturating_sub(1) - }; - - // Limit the scroll region to the lines from the top of the screen to the - // top of the viewport. With this in place, when we add lines inside this - // area, only the lines in this area will be scrolled. We place the cursor - // at the end of the scroll region, and add lines starting there. - // - // ┌─Screen───────────────────────┐ - // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ - // │┆ ┆│ - // │┆ ┆│ - // │┆ ┆│ - // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ - // │╭─Viewport───────────────────╮│ - // ││ ││ - // │╰────────────────────────────╯│ - // └──────────────────────────────┘ - queue!(writer, SetScrollRegion(1..area.top()))?; - - // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the - // terminal's last_known_cursor_position, which hopefully will still be accurate after we - // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) - queue!(writer, MoveTo(0, cursor_top))?; - - for line in wrapped { - queue!(writer, Print("\r\n"))?; - // URL lines can be wider than the terminal and will - // character-wrap onto continuation rows. Pre-clear those rows - // so stale content from a previously longer line is erased. - let physical_rows = line.width().max(1).div_ceil(wrap_width); - if physical_rows > 1 { - queue!(writer, SavePosition)?; - for _ in 1..physical_rows { - queue!(writer, MoveDown(1), MoveToColumn(0))?; - queue!(writer, Clear(ClearType::UntilNewLine))?; + if scroll_up_amount > 0 { + // Scroll the entire screen up by emitting \n at the bottom + queue!(writer, MoveTo(0, screen_size.height.saturating_sub(1)))?; + for _ in 0..scroll_up_amount { + queue!(writer, Print("\n"))?; } - queue!(writer, RestorePosition)?; } - queue!( - writer, - SetColors(Colors::new( - line.style - .fg - .map(std::convert::Into::into) - .unwrap_or(CColor::Reset), - line.style - .bg - .map(std::convert::Into::into) - .unwrap_or(CColor::Reset) - )) - )?; - queue!(writer, Clear(ClearType::UntilNewLine))?; - // Merge line-level style into each span so that ANSI colors reflect - // line styles (e.g., blockquotes with green fg). - let merged_spans: Vec = line - .spans - .iter() - .map(|s| Span { - style: s.style.patch(line.style), - content: s.content.clone(), - }) - .collect(); - write_spans(writer, merged_spans.iter())?; - } - queue!(writer, ResetScrollRegion)?; + 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(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)?; + } + } else { + let cursor_top = if area.bottom() < screen_size.height { + let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); + + let top_1based = area.top() + 1; + queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; + queue!(writer, MoveTo(0, area.top()))?; + for _ in 0..scroll_amount { + queue!(writer, Print("\x1bM"))?; + } + queue!(writer, ResetScrollRegion)?; + + let cursor_top = area.top().saturating_sub(1); + area.y += scroll_amount; + should_update_area = true; + cursor_top + } else { + area.top().saturating_sub(1) + }; + + // Limit the scroll region to the lines from the top of the screen to the + // top of the viewport. With this in place, when we add lines inside this + // area, only the lines in this area will be scrolled. We place the cursor + // at the end of the scroll region, and add lines starting there. + // + // ┌─Screen───────────────────────┐ + // │┌╌Scroll region╌╌╌╌╌╌╌╌╌╌╌╌╌╌┐│ + // │┆ ┆│ + // │┆ ┆│ + // │┆ ┆│ + // │█╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌┘│ + // │╭─Viewport───────────────────╮│ + // ││ ││ + // │╰────────────────────────────╯│ + // └──────────────────────────────┘ + queue!(writer, SetScrollRegion(1..area.top()))?; + + // NB: we are using MoveTo instead of set_cursor_position here to avoid messing with the + // terminal's last_known_cursor_position, which hopefully will still be accurate after we + // fetch/restore the cursor position. insert_history_lines should be cursor-position-neutral :) + queue!(writer, MoveTo(0, cursor_top))?; + + for line in &wrapped { + queue!(writer, Print("\r\n"))?; + write_history_line(writer, line, wrap_width)?; + } + + 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))?; @@ -181,6 +207,46 @@ where Ok(()) } +/// 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`. +fn write_history_line(writer: &mut W, line: &Line, wrap_width: usize) -> io::Result<()> { + let physical_rows = line.width().max(1).div_ceil(wrap_width) as u16; + if physical_rows > 1 { + queue!(writer, SavePosition)?; + for _ in 1..physical_rows { + queue!(writer, MoveDown(1), MoveToColumn(0))?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + } + queue!(writer, RestorePosition)?; + } + queue!( + writer, + SetColors(Colors::new( + line.style + .fg + .map(std::convert::Into::into) + .unwrap_or(CColor::Reset), + line.style + .bg + .map(std::convert::Into::into) + .unwrap_or(CColor::Reset) + )) + )?; + queue!(writer, Clear(ClearType::UntilNewLine))?; + // Merge line-level style into each span so that ANSI colors reflect + // line styles (e.g., blockquotes with green fg). + let merged_spans: Vec = line + .spans + .iter() + .map(|s| Span { + style: s.style.patch(line.style), + content: s.content.clone(), + }) + .collect(); + write_spans(writer, merged_spans.iter()) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SetScrollRegion(pub std::ops::Range); @@ -733,4 +799,26 @@ mod tests { "expected URL content to appear immediately after prompt (allowing at most one spacer row), got prompt_row={prompt_row}, url_row={url_row}, rows={rows:?}", ); } + + #[test] + fn vt100_zellij_mode_inserts_history_and_updates_viewport() { + let width: u16 = 32; + let height: u16 = 8; + let backend = VT100Backend::new(width, height); + let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal"); + let viewport = Rect::new(0, 4, width, 2); + term.set_viewport_area(viewport); + + let line: Line<'static> = Line::from("zellij history"); + insert_history_lines_with_mode(&mut term, vec![line], InsertHistoryMode::Zellij) + .expect("insert zellij history"); + + let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + assert!( + rows.iter().any(|row| row.contains("zellij history")), + "expected zellij history row in screen output, rows: {rows:?}" + ); + assert_eq!(term.viewport_area, Rect::new(0, 5, width, 2)); + assert_eq!(term.visible_history_rows(), 1); + } } diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 2fd9ebd12..4c31516b5 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,7 +49,6 @@ use codex_protocol::protocol::RolloutLine; use codex_protocol::protocol::TurnContextItem; use codex_rollout::state_db::get_state_db; use codex_state::log_db; -use codex_terminal_detection::Multiplexer; use codex_terminal_detection::terminal_info; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_oss::ensure_oss_provider_ready; @@ -1550,7 +1549,7 @@ fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScree AltScreenMode::Never => false, AltScreenMode::Auto => { let terminal_info = terminal_info(); - !matches!(terminal_info.multiplexer, Some(Multiplexer::Zellij { .. })) + !terminal_info.is_zellij() } } } diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 0877835ba..78d9adb78 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -32,6 +32,7 @@ use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; use ratatui::layout::Offset; use ratatui::layout::Rect; +use ratatui::layout::Size; use ratatui::text::Line; use tokio::sync::broadcast; use tokio_stream::Stream; @@ -253,6 +254,7 @@ pub struct Tui { terminal_focused: Arc, enhanced_keys_supported: bool, notification_backend: Option, + is_zellij: bool, // When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support) alt_screen_enabled: bool, } @@ -268,6 +270,7 @@ 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 = codex_terminal_detection::terminal_info().is_zellij(); Self { frame_requester, @@ -282,6 +285,7 @@ impl Tui { terminal_focused: Arc::new(AtomicBool::new(true)), enhanced_keys_supported, notification_backend: Some(detect_backend(NotificationMethod::default())), + is_zellij, alt_screen_enabled: true, } } @@ -449,6 +453,82 @@ impl Tui { self.pending_history_lines.clear(); } + /// Resize the inline viewport to `height` rows, scrolling content above it if + /// the viewport would extend past the bottom of the screen. Returns `true` when + /// the caller must invalidate the diff buffer (Zellij mode), because the scroll + /// was performed with raw newlines that ratatui cannot track. + fn update_inline_viewport( + terminal: &mut Terminal, + height: u16, + is_zellij: bool, + ) -> Result { + let size = terminal.size()?; + let mut needs_full_repaint = false; + + let mut area = terminal.viewport_area; + area.height = height.min(size.height); + area.width = size.width; + if area.bottom() > size.height { + let scroll_by = area.bottom() - size.height; + if is_zellij { + Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?; + needs_full_repaint = true; + } else { + terminal + .backend_mut() + .scroll_region_up(0..area.top(), scroll_by)?; + } + area.y = size.height - area.height; + } + if area != terminal.viewport_area { + // TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above. + terminal.clear()?; + terminal.set_viewport_area(area); + } + + Ok(needs_full_repaint) + } + + /// Push content above the viewport upward by `scroll_by` rows using raw + /// newlines at the screen bottom. This is the Zellij-safe alternative to + /// `scroll_region_up`, which relies on DECSTBM sequences Zellij does not + /// support. + fn scroll_zellij_expanded_viewport( + terminal: &mut Terminal, + size: Size, + scroll_by: u16, + ) -> Result<()> { + crossterm::queue!( + terminal.backend_mut(), + crossterm::cursor::MoveTo(0, size.height.saturating_sub(1)) + )?; + for _ in 0..scroll_by { + crossterm::queue!(terminal.backend_mut(), crossterm::style::Print("\n"))?; + } + Ok(()) + } + + /// 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. + fn flush_pending_history_lines( + terminal: &mut Terminal, + pending_history_lines: &mut Vec>, + is_zellij: bool, + ) -> Result { + if pending_history_lines.is_empty() { + return Ok(false); + } + + crate::insert_history::insert_history_lines_with_mode( + terminal, + pending_history_lines.clone(), + crate::insert_history::InsertHistoryMode::new(is_zellij), + )?; + pending_history_lines.clear(); + Ok(is_zellij) + } + pub fn draw( &mut self, height: u16, @@ -477,31 +557,19 @@ impl Tui { terminal.clear()?; } - let size = terminal.size()?; + let mut needs_full_repaint = + Self::update_inline_viewport(terminal, height, self.is_zellij)?; + needs_full_repaint |= Self::flush_pending_history_lines( + terminal, + &mut self.pending_history_lines, + self.is_zellij, + )?; - let mut area = terminal.viewport_area; - area.height = height.min(size.height); - area.width = size.width; - // If the viewport has expanded, scroll everything else up to make room. - if area.bottom() > size.height { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), area.bottom() - size.height)?; - area.y = size.height - area.height; - } - if area != terminal.viewport_area { - // TODO(nornagon): probably this could be collapsed with the clear + set_viewport_area above. - terminal.clear()?; - terminal.set_viewport_area(area); + if needs_full_repaint { + terminal.invalidate_viewport(); } - if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - )?; - self.pending_history_lines.clear(); - } + let area = terminal.viewport_area; // Update the y position for suspending so Ctrl-Z can place the cursor correctly. #[cfg(unix)]