fix(tui): handle zellij redraw and composer rendering (#16578)

## 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<TerminalInfo>`, 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) <noreply@anthropic.com>
This commit is contained in:
fcoury-oai
2026-04-02 18:07:05 -03:00
committed by GitHub
parent 9bb7f0a694
commit 0bd31dc382
9 changed files with 444 additions and 125 deletions

View File

@@ -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<TerminalInfo> = OnceLock::new();

View File

@@ -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");

View File

@@ -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<Line<'static>>,
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<char>,
) {
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<F>(name: &str, setup: F)
where
F: FnOnce(&mut ChatComposer),
{
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
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;

View File

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

View File

@@ -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<usize>],
range: std::ops::Range<usize>,
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<usize>],
range: std::ops::Range<usize>,
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::<String>();
buf.set_string(area.x, y, &masked, Style::default());
buf.set_string(area.x, y, &masked, base_style);
}
}
}

View File

@@ -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() {

View File

@@ -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<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line>,
) -> 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<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line>,
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<Span> = 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<W: Write>(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<Span> = 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<u16>);
@@ -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<String> = 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);
}
}

View File

@@ -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()
}
}
}

View File

@@ -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<AtomicBool>,
enhanced_keys_supported: bool,
notification_backend: Option<DesktopNotificationBackend>,
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<bool> {
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<Line<'static>>,
is_zellij: bool,
) -> Result<bool> {
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)]