fix: add tui.alternate_screen config and --no-alt-screen CLI flag for Zellij scrollback (#8555)

Fixes #2558

Codex uses alternate screen mode (CSI 1049) which, per xterm spec,
doesn't support scrollback. Zellij follows this strictly, so users can't
scroll back through output.

**Changes:**
- Add `tui.alternate_screen` config: `auto` (default), `always`, `never`
- Add `--no-alt-screen` CLI flag
- Auto-detect Zellij and skip alt screen (uses existing `ZELLIJ` env var
detection)

**Usage:**
```bash
# CLI flag
codex --no-alt-screen

# Or in config.toml
[tui]
alternate_screen = "never"
```

With default `auto` mode, Zellij users get working scrollback without
any config changes.

---------

Co-authored-by: Josh McKinney <joshka@openai.com>
This commit is contained in:
Helmut Januschka 2026-01-09 19:38:26 +01:00 committed by GitHub
parent 1aed01e99f
commit 7daaabc795
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 310 additions and 3 deletions

View file

@ -95,7 +95,6 @@ function detectPackageManager() {
return "bun";
}
if (
__dirname.includes(".bun/install/global") ||
__dirname.includes(".bun\\install\\global")

View file

@ -32,6 +32,7 @@ use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use codex_app_server_protocol::Tools;
use codex_app_server_protocol::UserSavedConfig;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::ForcedLoginMethod;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::config_types::SandboxMode;
@ -236,6 +237,14 @@ pub struct Config {
/// consistently to both mouse wheels and trackpads.
pub tui_scroll_invert: bool,
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// This is the same `tui.alternate_screen` value from `config.toml` (see [`Tui`]).
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
/// - `always`: Always use alternate screen (original behavior).
/// - `never`: Never use alternate screen (inline mode, preserves scrollback).
pub tui_alternate_screen: AltScreenMode,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
@ -1443,6 +1452,11 @@ impl Config {
.as_ref()
.and_then(|t| t.scroll_wheel_like_max_duration_ms),
tui_scroll_invert: cfg.tui.as_ref().map(|t| t.scroll_invert).unwrap_or(false),
tui_alternate_screen: cfg
.tui
.as_ref()
.map(|t| t.alternate_screen)
.unwrap_or_default(),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@ -1641,6 +1655,7 @@ persistence = "none"
scroll_wheel_tick_detect_max_ms: None,
scroll_wheel_like_max_duration_ms: None,
scroll_invert: false,
alternate_screen: AltScreenMode::Auto,
}
);
}
@ -3276,6 +3291,7 @@ model_verbosity = "high"
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
tui_alternate_screen: AltScreenMode::Auto,
otel: OtelConfig::default(),
},
o3_profile_config
@ -3361,6 +3377,7 @@ model_verbosity = "high"
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
tui_alternate_screen: AltScreenMode::Auto,
otel: OtelConfig::default(),
};
@ -3461,6 +3478,7 @@ model_verbosity = "high"
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
tui_alternate_screen: AltScreenMode::Auto,
otel: OtelConfig::default(),
};
@ -3547,6 +3565,7 @@ model_verbosity = "high"
tui_scroll_wheel_tick_detect_max_ms: None,
tui_scroll_wheel_like_max_duration_ms: None,
tui_scroll_invert: false,
tui_alternate_screen: AltScreenMode::Auto,
otel: OtelConfig::default(),
};

View file

@ -3,6 +3,7 @@
// Note this file should generally be restricted to simple struct/enum
// definitions that do not contain business logic.
pub use codex_protocol::config_types::AltScreenMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::BTreeMap;
use std::collections::HashMap;
@ -523,6 +524,17 @@ pub struct Tui {
/// wheel and trackpad input.
#[serde(default)]
pub scroll_invert: bool,
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// - `auto` (default): Disable alternate screen in Zellij, enable elsewhere.
/// - `always`: Always use alternate screen (original behavior).
/// - `never`: Never use alternate screen (inline mode only, preserves scrollback).
///
/// Using alternate screen provides a cleaner fullscreen experience but prevents
/// scrollback in terminal multiplexers like Zellij that follow the xterm spec.
#[serde(default)]
pub alternate_screen: AltScreenMode,
}
const fn default_true() -> bool {

View file

@ -80,3 +80,38 @@ pub enum TrustLevel {
Trusted,
Untrusted,
}
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// **Background:** The alternate screen buffer provides a cleaner fullscreen experience
/// without polluting the terminal's scrollback history. However, it conflicts with terminal
/// multiplexers like Zellij that strictly follow the xterm specification, which defines
/// that alternate screen buffers should not have scrollback.
///
/// **Zellij's behavior:** Zellij intentionally disables scrollback in alternate screen mode
/// (see https://github.com/zellij-org/zellij/pull/1032) to comply with the xterm spec. This
/// is by design and not configurable in Zellij—there is no option to enable scrollback in
/// alternate screen mode.
///
/// **Solution:** This setting provides a pragmatic workaround:
/// - `auto` (default): Automatically detect the terminal multiplexer. If running in Zellij,
/// disable alternate screen to preserve scrollback. Enable it everywhere else.
/// - `always`: Always use alternate screen mode (original behavior before this fix).
/// - `never`: Never use alternate screen mode. Runs in inline mode, preserving scrollback
/// in all multiplexers.
///
/// The CLI flag `--no-alt-screen` can override this setting at runtime.
#[derive(
Debug, Serialize, Deserialize, Default, Clone, Copy, PartialEq, Eq, Display, JsonSchema, TS,
)]
#[serde(rename_all = "lowercase")]
#[strum(serialize_all = "lowercase")]
pub enum AltScreenMode {
/// Auto-detect: disable alternate screen in Zellij, enable elsewhere.
#[default]
Auto,
/// Always use alternate screen (original behavior).
Always,
/// Never use alternate screen (inline mode only).
Never,
}

View file

@ -85,6 +85,14 @@ pub struct Cli {
#[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)]
pub add_dir: Vec<PathBuf>,
/// Disable alternate screen mode
///
/// Runs the TUI in inline mode, preserving terminal scrollback history. This is useful
/// in terminal multiplexers like Zellij that follow the xterm spec strictly and disable
/// scrollback in alternate screen buffers.
#[arg(long = "no-alt-screen", default_value_t = false)]
pub no_alt_screen: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}

View file

@ -22,6 +22,8 @@ use codex_core::config::resolve_oss_provider;
use codex_core::find_thread_path_by_id_str;
use codex_core::get_platform_sandbox;
use codex_core::protocol::AskForApproval;
use codex_core::terminal::Multiplexer;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::SandboxMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs::OpenOptions;
@ -493,7 +495,15 @@ async fn run_ratatui_app(
resume_picker::ResumeSelection::StartFresh
};
let Cli { prompt, images, .. } = cli;
let Cli {
prompt,
images,
no_alt_screen,
..
} = cli;
let use_alt_screen = determine_alt_screen_mode(no_alt_screen, config.tui_alternate_screen);
tui.set_alt_screen_enabled(use_alt_screen);
let app_result = App::run(
&mut tui,
@ -527,6 +537,37 @@ fn restore() {
}
}
/// Determine whether to use the terminal's alternate screen buffer.
///
/// The alternate screen buffer provides a cleaner fullscreen experience without polluting
/// the terminal's scrollback history. However, it conflicts with terminal multiplexers like
/// Zellij that strictly follow the xterm spec, which disallows scrollback in alternate screen
/// buffers. Zellij intentionally disables scrollback in alternate screen mode (see
/// https://github.com/zellij-org/zellij/pull/1032) and offers no configuration option to
/// change this behavior.
///
/// This function implements a pragmatic workaround:
/// - If `--no-alt-screen` is explicitly passed, always disable alternate screen
/// - Otherwise, respect the `tui.alternate_screen` config setting:
/// - `always`: Use alternate screen everywhere (original behavior)
/// - `never`: Inline mode only, preserves scrollback
/// - `auto` (default): Auto-detect the terminal multiplexer and disable alternate screen
/// only in Zellij, enabling it everywhere else
fn determine_alt_screen_mode(no_alt_screen: bool, tui_alternate_screen: AltScreenMode) -> bool {
if no_alt_screen {
false
} else {
match tui_alternate_screen {
AltScreenMode::Always => true,
AltScreenMode::Never => false,
AltScreenMode::Auto => {
let terminal_info = codex_core::terminal::terminal_info();
!matches!(terminal_info.multiplexer, Some(Multiplexer::Zellij { .. }))
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LoginStatus {
AuthMode(AuthMode),

View file

@ -247,6 +247,8 @@ pub struct Tui {
terminal_focused: Arc<AtomicBool>,
enhanced_keys_supported: bool,
notification_backend: Option<DesktopNotificationBackend>,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
}
impl Tui {
@ -274,9 +276,15 @@ impl Tui {
terminal_focused: Arc::new(AtomicBool::new(true)),
enhanced_keys_supported,
notification_backend: Some(detect_backend()),
alt_screen_enabled: true,
}
}
/// Set whether alternate screen is enabled. When false, enter_alt_screen() becomes a no-op.
pub fn set_alt_screen_enabled(&mut self, enabled: bool) {
self.alt_screen_enabled = enabled;
}
pub fn frame_requester(&self) -> FrameRequester {
self.frame_requester.clone()
}
@ -407,6 +415,9 @@ impl Tui {
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
/// inline viewport for restoration when leaving.
pub fn enter_alt_screen(&mut self) -> Result<()> {
if !self.alt_screen_enabled {
return Ok(());
}
let _ = execute!(self.terminal.backend_mut(), EnterAlternateScreen);
// Enable "alternate scroll" so terminals may translate wheel to arrows
let _ = execute!(self.terminal.backend_mut(), EnableAlternateScroll);
@ -426,6 +437,9 @@ impl Tui {
/// Leave alternate screen and restore the previously saved inline viewport, if any.
pub fn leave_alt_screen(&mut self) -> Result<()> {
if !self.alt_screen_enabled {
return Ok(());
}
// Disable alternate scroll when leaving alt-screen
let _ = execute!(self.terminal.backend_mut(), DisableAlternateScroll);
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen);

View file

@ -85,6 +85,11 @@ pub struct Cli {
#[arg(long = "add-dir", value_name = "DIR", value_hint = ValueHint::DirPath)]
pub add_dir: Vec<PathBuf>,
/// Disable alternate screen mode for better scrollback in terminal multiplexers like Zellij.
/// This runs the TUI in inline mode, preserving terminal scrollback history.
#[arg(long = "no-alt-screen", default_value_t = false)]
pub no_alt_screen: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
}
@ -109,6 +114,7 @@ impl From<codex_tui::Cli> for Cli {
cwd: cli.cwd,
web_search: cli.web_search,
add_dir: cli.add_dir,
no_alt_screen: cli.no_alt_screen,
config_overrides: cli.config_overrides,
}
}

View file

@ -22,6 +22,8 @@ use codex_core::config::resolve_oss_provider;
use codex_core::find_thread_path_by_id_str;
use codex_core::get_platform_sandbox;
use codex_core::protocol::AskForApproval;
use codex_core::terminal::Multiplexer;
use codex_protocol::config_types::AltScreenMode;
use codex_protocol::config_types::SandboxMode;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::fs::OpenOptions;
@ -515,12 +517,39 @@ async fn run_ratatui_app(
resume_picker::ResumeSelection::StartFresh
};
let Cli { prompt, images, .. } = cli;
let Cli {
prompt,
images,
no_alt_screen,
..
} = cli;
// Run the main chat + transcript UI on the terminal's alternate screen so
// the entire viewport can be used without polluting normal scrollback. This
// mirrors the behavior of the legacy TUI but keeps inline mode available
// for smaller prompts like onboarding and model migration.
//
// However, alternate screen prevents scrollback in terminal multiplexers like
// Zellij that strictly follow the xterm spec (which disallows scrollback in
// alternate screen buffers). This auto-detects the terminal and disables
// alternate screen in Zellij while keeping it enabled elsewhere.
let use_alt_screen = if no_alt_screen {
// CLI flag explicitly disables alternate screen
false
} else {
match config.tui_alternate_screen {
AltScreenMode::Always => true,
AltScreenMode::Never => false,
AltScreenMode::Auto => {
// Auto-detect: disable in Zellij, enable elsewhere
let terminal_info = codex_core::terminal::terminal_info();
!matches!(terminal_info.multiplexer, Some(Multiplexer::Zellij { .. }))
}
}
};
// Set flag on Tui so all enter_alt_screen() calls respect the setting
tui.set_alt_screen_enabled(use_alt_screen);
let _ = tui.enter_alt_screen();
let app_result = App::run(

View file

@ -143,6 +143,8 @@ pub struct Tui {
terminal_focused: Arc<AtomicBool>,
enhanced_keys_supported: bool,
notification_backend: Option<DesktopNotificationBackend>,
// When false, enter_alt_screen() becomes a no-op (for Zellij scrollback support)
alt_screen_enabled: bool,
}
impl Tui {
@ -170,9 +172,15 @@ impl Tui {
terminal_focused: Arc::new(AtomicBool::new(true)),
enhanced_keys_supported,
notification_backend: Some(detect_backend()),
alt_screen_enabled: true,
}
}
/// Set whether alternate screen is enabled. When false, enter_alt_screen() becomes a no-op.
pub fn set_alt_screen_enabled(&mut self, enabled: bool) {
self.alt_screen_enabled = enabled;
}
pub fn frame_requester(&self) -> FrameRequester {
self.frame_requester.clone()
}
@ -309,6 +317,9 @@ impl Tui {
/// Enter alternate screen and expand the viewport to full terminal size, saving the current
/// inline viewport for restoration when leaving.
pub fn enter_alt_screen(&mut self) -> Result<()> {
if !self.alt_screen_enabled {
return Ok(());
}
if !self.alt_screen_nesting.enter() {
self.alt_screen_active.store(true, Ordering::Relaxed);
return Ok(());
@ -330,6 +341,9 @@ impl Tui {
/// Leave alternate screen and restore the previously saved inline viewport, if any.
pub fn leave_alt_screen(&mut self) -> Result<()> {
if !self.alt_screen_enabled {
return Ok(());
}
if !self.alt_screen_nesting.leave() {
self.alt_screen_active
.store(self.alt_screen_nesting.is_active(), Ordering::Relaxed);

View file

@ -0,0 +1,130 @@
# TUI Alternate Screen and Terminal Multiplexers
## Overview
This document explains the design decision behind Codex's alternate screen handling, particularly in terminal multiplexers like Zellij. This addresses a fundamental conflict between fullscreen TUI behavior and terminal scrollback history preservation.
## The Problem
### Fullscreen TUI Benefits
Codex's TUI uses the terminal's **alternate screen buffer** to provide a clean fullscreen experience. This approach:
- Uses the entire viewport without polluting the terminal's scrollback history
- Provides a dedicated environment for the chat interface
- Mirrors the behavior of other terminal applications (vim, tmux, etc.)
### The Zellij Conflict
Terminal multiplexers like **Zellij** strictly follow the xterm specification, which defines that alternate screen buffers should **not** have scrollback. This is intentional design, not a bug:
- **Zellij PR:** https://github.com/zellij-org/zellij/pull/1032
- **Rationale:** The xterm spec explicitly states that alternate screen mode disallows scrollback
- **Configurability:** This is not configurable in Zellij—there is no option to enable scrollback in alternate screen mode
When using Codex's TUI in Zellij, users cannot scroll back through the conversation history because:
1. The TUI runs in alternate screen mode (fullscreen)
2. Zellij disables scrollback in alternate screen buffers (per xterm spec)
3. The entire conversation becomes inaccessible via normal terminal scrolling
## The Solution
Codex implements a **pragmatic workaround** with three modes, controlled by `tui.alternate_screen` in `config.toml`:
### 1. `auto` (default)
- **Behavior:** Automatically detect the terminal multiplexer
- **In Zellij:** Disable alternate screen mode (inline mode, preserves scrollback)
- **Elsewhere:** Enable alternate screen mode (fullscreen experience)
- **Rationale:** Provides the best UX in each environment
### 2. `always`
- **Behavior:** Always use alternate screen mode (original behavior)
- **Use case:** Users who prefer fullscreen and don't use Zellij, or who have found a workaround
### 3. `never`
- **Behavior:** Never use alternate screen mode (inline mode)
- **Use case:** Users who always want scrollback history preserved
- **Trade-off:** Pollutes the terminal scrollback with TUI output
## Runtime Override
The `--no-alt-screen` CLI flag can override the config setting at runtime:
```bash
codex --no-alt-screen
```
This runs the TUI in inline mode regardless of the configuration, useful for:
- One-off sessions where scrollback is critical
- Debugging terminal-related issues
- Testing alternate screen behavior
## Implementation Details
### Auto-Detection
The `auto` mode detects Zellij by checking the `ZELLIJ` environment variable:
```rust
let terminal_info = codex_core::terminal::terminal_info();
!matches!(terminal_info.multiplexer, Some(Multiplexer::Zellij { .. }))
```
This detection happens in the helper function `determine_alt_screen_mode()` in `codex-rs/tui/src/lib.rs`.
### Configuration Schema
The `AltScreenMode` enum is defined in `codex-rs/protocol/src/config_types.rs` and serializes to lowercase TOML:
```toml
[tui]
# Options: auto, always, never
alternate_screen = "auto"
```
### Why Not Just Disable Alternate Screen in Zellij Permanently?
We use `auto` detection instead of always disabling in Zellij because:
1. Many Zellij users don't care about scrollback and prefer the fullscreen experience
2. Some users may use tmux inside Zellij, creating a chain of multiplexers
3. Provides user choice without requiring manual configuration
## Related Issues and References
- **Original Issue:** [GitHub #2558](https://github.com/openai/codex/issues/2558) - "No scrollback in Zellij"
- **Implementation PR:** [GitHub #8555](https://github.com/openai/codex/pull/8555)
- **Zellij PR:** https://github.com/zellij-org/zellij/pull/1032 (why scrollback is disabled)
- **xterm Spec:** Alternate screen buffers should not have scrollback
## Future Considerations
### Alternative Approaches Considered
1. **Implement custom scrollback in TUI:** Would require significant architectural changes to buffer and render all historical output
2. **Request Zellij to add a config option:** Not viable—Zellij maintainers explicitly chose this behavior to follow the spec
3. **Disable alternate screen unconditionally:** Would degrade UX for non-Zellij users
### Transcript Pager
Codex's transcript pager (opened with Ctrl+T) provides an alternative way to review conversation history, even in fullscreen mode. However, this is not as seamless as natural scrollback.
## For Developers
When modifying TUI code, remember:
- The `determine_alt_screen_mode()` function encapsulates all the logic
- Configuration is in `config.tui_alternate_screen`
- CLI flag is in `cli.no_alt_screen`
- The behavior is applied via `tui.set_alt_screen_enabled()`
If you encounter issues with terminal state after running Codex, you can restore your terminal with:
```bash
reset
```