mirror of
https://github.com/openai/codex.git
synced 2026-04-24 06:35:50 +00:00
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:
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 "
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user