Move fullscreen transcript control to TUI configuration (#46849)

## What changed

Use `tui.fullscreen_transcript` to opt into the fullscreen transcript, including scrolling, selection, and search. It defaults to `false`; `--no-alt-screen` and `tui.alternate_screen = "never"` still take precedence.

The new preference controls both the first frame and application startup. Boolean CLI overrides for it are allowed during daemon startup. The old `features.transcript_v2` flag is deprecated and ignored, with a notice directing users to the new setting; it does not migrate into or override the preference.

## Testing

Add coverage for default terminal scrollback, fullscreen persistence through startup, alternate-screen restrictions, daemon override validation, and independence from the deprecated flag. Update existing history and activity tests to use the new preference.

GitOrigin-RevId: 4117bf1ee548fa8d101a594f9506673d87c74c15
This commit is contained in:
Eric Traut
2026-09-20 16:59:00 +00:00
committed by copyberry
parent 79ef00ded1
commit a2de8fedcc
20 changed files with 106 additions and 75 deletions
+1 -1
View File
@@ -133,7 +133,7 @@ fn strict_config_is_not_supported_for_cloud_command() -> Result<()> {
async fn features_enable_writes_feature_flag_to_config() -> Result<()> {
let codex_home = TempDir::new()?;
for feature in ["unified_exec", "transcript_v2"] {
for feature in ["unified_exec", "shell_tool"] {
let mut cmd = codex_command(codex_home.path())?;
cmd.args(["features", "enable", feature])
.assert()
+5
View File
@@ -793,6 +793,11 @@ pub struct Tui {
#[serde(default)]
pub raw_output_mode: bool,
/// Own the fullscreen transcript, including scrolling, selection, and search.
/// Defaults to `false`; alternate-screen restrictions take precedence.
#[serde(default)]
pub fullscreen_transcript: bool,
/// Controls whether the TUI uses the terminal's alternate screen buffer.
///
/// - `auto` (default): Use alternate screen.
+5
View File
@@ -4226,6 +4226,11 @@
},
"description": "Individual visual effects. Each also requires animations to be enabled."
},
"fullscreen_transcript": {
"default": false,
"description": "Own the fullscreen transcript, including scrolling, selection, and search. Defaults to `false`; alternate-screen restrictions take precedence.",
"type": "boolean"
},
"keymap": {
"allOf": [
{
+2
View File
@@ -1261,6 +1261,7 @@ fn config_toml_deserializes_model_availability_nux() {
vim_mode_default: false,
question_esc_back: true,
raw_output_mode: false,
fullscreen_transcript: false,
alternate_screen: AltScreenMode::default(),
status_line: None,
status_line_use_colors: true,
@@ -4388,6 +4389,7 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() {
vim_mode_default: false,
question_esc_back: true,
raw_output_mode: false,
fullscreen_transcript: false,
alternate_screen: AltScreenMode::Auto,
status_line: None,
status_line_use_colors: true,
+7
View File
@@ -770,6 +770,9 @@ pub struct Config {
/// Start the TUI in raw scrollback mode for copy-friendly transcript output.
pub tui_raw_output_mode: bool,
/// Own the fullscreen transcript when the alternate screen is enabled.
pub tui_fullscreen_transcript: bool,
/// Start the TUI in the specified collaboration mode (plan/default).
/// Controls whether the TUI uses the terminal's alternate screen buffer.
@@ -4406,6 +4409,10 @@ impl Config {
.as_ref()
.map(|t| t.raw_output_mode)
.unwrap_or(false),
tui_fullscreen_transcript: cfg
.tui
.as_ref()
.is_some_and(|tui| tui.fullscreen_transcript),
tui_alternate_screen: cfg
.tui
.as_ref()
+10 -2
View File
@@ -95,7 +95,7 @@ pub enum Feature {
AnalyticsPlanHistory,
/// Discover model catalogs for OpenAI API-key authentication.
ApiKeyModelDiscovery,
/// Enable the interactive transcript composer and turn-selection UI.
/// Deprecated no-op; use `tui.fullscreen_transcript` instead.
TranscriptV2,
// Stable.
/// Enable the default shell tool.
@@ -592,6 +592,10 @@ impl Features {
Feature::WebSearchCached,
);
}
"transcript_v2" => {
self.record_legacy_usage_force("features.transcript_v2", Feature::TranscriptV2);
continue;
}
"tui_app_server" => {
continue;
}
@@ -696,6 +700,10 @@ impl Features {
fn legacy_usage_notice(alias: &str, feature: Feature) -> (String, Option<String>) {
let canonical = feature.key();
match feature {
Feature::TranscriptV2 => (
"`[features].transcript_v2` is deprecated and ignored.".to_string(),
Some("Use `[tui].fullscreen_transcript` in config.toml instead.".to_string()),
),
Feature::WebSearchRequest | Feature::WebSearchCached => {
let label = match alias {
"web_search" => "[features].web_search",
@@ -937,7 +945,7 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::TranscriptV2,
key: "transcript_v2",
stage: Stage::UnderDevelopment,
stage: Stage::Deprecated,
default_enabled: false,
},
// Stable features.
-9
View File
@@ -14,15 +14,6 @@ use std::collections::BTreeMap;
use toml::Table;
use toml::Value as TomlValue;
#[test]
fn transcript_v2_resolves_explicit_config_overrides() {
let mut features = Features::with_defaults();
for enabled in [false, true, false] {
features.apply_map(&BTreeMap::from([("transcript_v2".to_string(), enabled)]));
assert_eq!(features.enabled(Feature::TranscriptV2), enabled);
}
}
#[test]
fn sleep_tool_config_rejects_unknown_mode() {
assert!(toml::from_str::<FeaturesToml>("[sleep_tool]\nmode = 'off'").is_err());
@@ -232,6 +232,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
tui_show_server_version_notice: true,
tui_auto_recap: true,
model_availability_nux: ModelAvailabilityNuxConfig::default(),
tui_fullscreen_transcript: false,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
tui_status_line_use_colors: true,
+1 -1
View File
@@ -194,7 +194,7 @@ impl App {
}
// Adopt actual launch ownership before constructing session-local preferences.
tui.prepare_owned_screen(config.features.enabled(Feature::TranscriptV2))?;
tui.prepare_owned_screen(config.tui_fullscreen_transcript)?;
let mut local_settings = crate::local_settings::LocalSettings::for_tui(&config, tui);
let startup_started_at = Instant::now();
let (app_event_tx, mut app_event_rx) = unbounded_channel();
@@ -247,9 +247,7 @@ async fn history_hydration_archived_retry_uses_first_attempt_runtime_settings()
] {
for mode in [TranscriptMode::Terminal, TranscriptMode::Owned] {
let (mut app, codex_home, mut target) = history_fixture(&[500]).await?;
app.config
.features
.set_enabled(Feature::TranscriptV2, !mode.is_owned())?;
app.config.tui_fullscreen_transcript = !mode.is_owned();
app.config.tui_alternate_screen = codex_config::types::AltScreenMode::Always;
app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(1);
app.local_settings = crate::local_settings::LocalSettings::from(&app.config);
@@ -5,7 +5,6 @@ use crate::legacy_core::config::ConfigBuilder;
use crate::legacy_core::config::TerminalResizeReflowMaxRows;
use crate::local_settings::LocalSettings;
use codex_config::types::AltScreenMode;
use codex_features::Feature;
use pretty_assertions::assert_eq;
use std::collections::HashSet;
@@ -42,10 +41,7 @@ async fn owned_initial_history_stops_after_viewport_or_scan_budget() {
.build()
.await
.expect("config");
config
.features
.enable(Feature::TranscriptV2)
.expect("enable owned transcript");
config.tui_fullscreen_transcript = true;
config.tui_alternate_screen = AltScreenMode::Always;
config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled;
let local_settings = LocalSettings::from(&config);
@@ -78,10 +74,7 @@ async fn owned_initial_history_stops_after_viewport_or_scan_budget() {
Some(100),
);
config
.features
.disable(Feature::TranscriptV2)
.expect("disable owned transcript");
config.tui_fullscreen_transcript = false;
// The mode selected at launch wins even if thread config changes or is unavailable.
for config in [Some(&config), None] {
let budget = HistoryLoadBudget::new(
@@ -37,10 +37,7 @@ async fn terminal_dynamic_activity_retains_calls_across_both_fallbacks() {
[(false, AltScreenMode::Always), (true, AltScreenMode::Never)]
{
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config
.features
.set_enabled(Feature::TranscriptV2, owned_enabled)
.expect("configure transcript feature");
chat.config.tui_fullscreen_transcript = owned_enabled;
chat.config.tui_alternate_screen = alternate_screen;
chat.local_settings = LocalSettings::from(&chat.config);
assert_eq!(
@@ -89,10 +86,7 @@ async fn owned_dynamic_activity_updates_the_retained_row_after_config_refresh()
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.local_settings.transcript_mode = TranscriptMode::Owned;
// A thread/config refresh cannot change ownership after the terminal starts.
chat.config
.features
.disable(Feature::TranscriptV2)
.expect("disable transcript feature in refreshed config");
chat.config.tui_fullscreen_transcript = false;
drain_insert_history(&mut rx);
chat.on_dynamic_tool_item(dynamic_item("call-1", DynamicToolCallStatus::InProgress));
let retained = std::iter::from_fn(|| rx.try_recv().ok())
+4 -1
View File
@@ -52,7 +52,10 @@ pub(super) fn config_exclusion(
if !cli_kv_overrides
.iter()
.all(|(key, value)| match key.as_str() {
"suppress_unstable_features_warning" => value.is_bool(),
"suppress_unstable_features_warning" | "tui.fullscreen_transcript" => value.is_bool(),
"tui" => value.as_table().is_some_and(|tui| {
tui.len() == 1 && tui.get("fullscreen_transcript").is_some_and(toml::Value::is_bool)
}),
"features" => value.as_table().is_some_and(|features| {
!features.is_empty()
&& features
+6
View File
@@ -27,6 +27,12 @@ fn audited_overrides_allow_daemon_without_allowing_arbitrary_config() {
("features={worktrees=true,shell_tool=false}", false),
("features.shell_tool=false", false),
("features.worktrees.enabled=true", false),
("tui.fullscreen_transcript=true", true),
("tui.fullscreen_transcript=false", true),
("tui.fullscreen_transcript=\"true\"", false),
("tui={fullscreen_transcript=true}", true),
("tui={fullscreen_transcript='true'}", false),
("tui={fullscreen_transcript=true,animations=false}", false),
("features={}", false),
("model='test'", false),
] {
+1 -1
View File
@@ -1843,7 +1843,7 @@ async fn run_ratatui_app(
// Cloud configuration and session selection can change screen policy after first paint.
let use_alt_screen = determine_alt_screen_mode(cli.no_alt_screen, config.tui_alternate_screen);
let mode = crate::transcript_mode::TranscriptMode::resolve(
config.features.enabled(Feature::TranscriptV2),
config.tui_fullscreen_transcript,
use_alt_screen,
);
if use_alt_screen != tui.is_alt_screen_enabled() || mode.is_owned() != tui.is_owned_screen() {
+2 -2
View File
@@ -13,7 +13,6 @@ use crate::transcript_mode::TranscriptMode;
use codex_config::types::History;
use codex_config::types::Notice;
use codex_config::types::Tui;
use codex_features::Feature;
use codex_utils_absolute_path::AbsolutePathBuf;
#[derive(Clone, Debug, PartialEq)]
@@ -56,7 +55,7 @@ impl LocalSettings {
};
Self {
transcript_mode: TranscriptMode::resolve(
config.features.enabled(Feature::TranscriptV2),
config.tui_fullscreen_transcript,
config.tui_alternate_screen != codex_config::types::AltScreenMode::Never,
),
tui: Tui {
@@ -71,6 +70,7 @@ impl LocalSettings {
vim_mode_default: config.tui_vim_mode_default,
question_esc_back: config.tui_question_esc_back,
raw_output_mode: config.tui_raw_output_mode,
fullscreen_transcript: config.tui_fullscreen_transcript,
alternate_screen: config.tui_alternate_screen,
status_line: config.tui_status_line.clone(),
status_line_use_colors: config.tui_status_line_use_colors,
+24 -3
View File
@@ -16,7 +16,7 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<()
.loader_overrides(LoaderOverrides::without_managed_config_for_tests())
.build()
.await?;
config.features.enable(Feature::TranscriptV2)?;
config.tui_fullscreen_transcript = true;
config.tui_alternate_screen = AltScreenMode::Auto;
for (alternate_screen, owned, expected_mode, expected_alt) in [
@@ -34,7 +34,7 @@ async fn launch_screen_mode_survives_configuration_reload() -> anyhow::Result<()
);
let mut reloaded_config = config.clone();
reloaded_config.features.disable(Feature::TranscriptV2)?;
reloaded_config.tui_fullscreen_transcript = false;
reloaded_config.tui_alternate_screen = AltScreenMode::Never;
reloaded_config.tui_theme = Some("nord".into());
let mut expected = LocalSettings::from(&reloaded_config);
@@ -98,6 +98,7 @@ whimsy = false # Retired: must not override effects or prevent strict loading.
show_tooltips = false
show_server_version_notice = false
auto_recap = false
fullscreen_transcript = true
vim_mode_default = true
terminal_resize_reflow_max_rows = 0
session_picker_view = "comfortable"
@@ -119,7 +120,14 @@ fast_default_opt_out = true
ignore_project_config: true,
..LoaderOverrides::without_managed_config_for_tests()
})
.cli_overrides(vec![("tui.disable_paste_burst".into(), true.into())])
.cli_overrides(vec![
("tui.disable_paste_burst".into(), true.into()),
// The deprecated flag must not override or migrate into the TUI preference.
(
"features.transcript_v2".into(),
config_text.is_empty().into(),
),
])
.build()
.await?;
assert_eq!(config.startup_warnings, Vec::<String>::new());
@@ -133,11 +141,24 @@ fast_default_opt_out = true
expected.show_tooltips = false;
expected.show_server_version_notice = false;
expected.auto_recap = false;
expected.fullscreen_transcript = true;
expected.vim_mode_default = true;
expected.terminal_resize_reflow_max_rows = Some(0);
expected.session_picker_view = Some(SessionPickerViewMode::Comfortable);
}
assert_eq!(
local.transcript_mode.is_owned(),
expected.fullscreen_transcript
);
assert_eq!(local.tui, expected);
assert_eq!(
config
.features
.legacy_feature_usages()
.map(|usage| usage.alias.as_str())
.collect::<Vec<_>>(),
vec!["features.transcript_v2"],
);
assert_eq!(
local.terminal_resize_reflow(),
config.terminal_resize_reflow
+4 -25
View File
@@ -12,16 +12,11 @@ use codex_config::CloudConfigBundleLoader;
use codex_config::ConfigLoadOptions;
use codex_config::LoaderOverrides;
use codex_config::TomlValue;
use codex_features::Feature;
use codex_features::FeatureConfigSource;
use codex_features::FeatureOverrides;
use codex_features::Features;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::Cli;
use crate::keymap::RuntimeKeymap;
use crate::legacy_core::config::ConfigTomlLoadResult;
use crate::legacy_core::config::ManagedFeatures;
use crate::legacy_core::config::load_config_toml_with_layer_stack;
use crate::startup_draft::StartupScreen;
@@ -56,25 +51,6 @@ pub(super) async fn load(
)
.await?;
let config_toml = &bootstrap_config.config_toml;
let configured_features = Features::from_sources(
FeatureConfigSource {
features: config_toml.features.as_ref(),
experimental_use_unified_exec_tool: config_toml.experimental_use_unified_exec_tool,
},
FeatureConfigSource::default(),
FeatureOverrides::default(),
);
// Full configuration construction will publish its normal managed-feature warnings once.
let mut startup_warnings = Vec::new();
let features = ManagedFeatures::from_configured_with_warnings(
configured_features,
bootstrap_config
.config_layer_stack
.requirements()
.feature_requirements
.clone(),
&mut startup_warnings,
)?;
let alternate_screen = config_toml
.tui
.as_ref()
@@ -82,7 +58,10 @@ pub(super) async fn load(
.unwrap_or_default();
let use_alt_screen = crate::determine_alt_screen_mode(cli.no_alt_screen, alternate_screen);
let transcript_mode = crate::transcript_mode::TranscriptMode::resolve(
features.enabled(Feature::TranscriptV2),
config_toml
.tui
.as_ref()
.is_some_and(|tui| tui.fullscreen_transcript),
use_alt_screen,
);
let status_line_enabled = config_toml
@@ -50,23 +50,24 @@ async fn selected_profile_controls_submit_before_the_first_frame() -> anyhow::Re
async fn first_frame_respects_screen_and_status_line_overrides() -> anyhow::Result<()> {
let home = tempfile::tempdir()?;
for (config, args, expected) in [
("", vec!["codex"], (true, false, true)),
(
"[features]\ntranscript_v2 = true\n",
"[tui]\nfullscreen_transcript = true\n",
vec!["codex"],
(true, true, true),
),
(
"[features]\ntranscript_v2 = true\n",
"[tui]\nfullscreen_transcript = true\n",
vec!["codex", "--no-alt-screen"],
(false, false, true),
),
(
"[features]\ntranscript_v2 = false\n",
"[tui]\nfullscreen_transcript = false\n",
vec!["codex"],
(true, false, true),
),
(
"[tui]\nalternate_screen = \"never\"\nstatus_line = []\n",
"[tui]\nfullscreen_transcript = true\nalternate_screen = \"never\"\nstatus_line = []\n",
vec!["codex"],
(false, false, false),
),
+23 -6
View File
@@ -161,12 +161,7 @@ fn owned_screen_entry_paints_before_sync_ends_and_exit_clears_inline_draft() ->
let mut terminal = PtyCodex::start(
&repo_root,
codex_home,
&[
"-c",
"tui.alternate_screen=\"always\"",
"-c",
"features.transcript_v2=true",
],
&["-c", "tui.fullscreen_transcript=true"],
)?;
terminal.wait_for_startup()?;
let deadline = Instant::now() + STARTUP_TIMEOUT;
@@ -180,6 +175,10 @@ fn owned_screen_entry_paints_before_sync_ends_and_exit_clears_inline_draft() ->
"owned screen did not open"
);
terminal.wait_for_screen("GPT-5.6-Terra")?;
ensure!(
terminal.parser.screen().alternate_screen(),
"fullscreen did not survive application startup"
);
let enter_alt = b"\x1b[?1049h";
let begin_sync = b"\x1b[?2026h";
let end_sync = b"\x1b[?2026l";
@@ -252,6 +251,24 @@ fn owned_screen_entry_paints_before_sync_ends_and_exit_clears_inline_draft() ->
Ok(())
}
#[test]
fn fullscreen_transcript_defaults_to_terminal_scrollback() -> Result<()> {
let repo_root = codex_utils_cargo_bin::repo_root()?;
let codex_home = tempfile::tempdir()?;
write_test_config(codex_home.path(), &repo_root)?;
let mut terminal = PtyCodex::start(&repo_root, codex_home, &[])?;
terminal.wait_for_startup()?;
terminal.wait_for_screen("GPT-5.6-Terra")?;
ensure!(
!terminal
.output
.windows(b"\x1b[?1049h".len())
.any(|bytes| bytes == b"\x1b[?1049h"),
"default launch entered the alternate screen"
);
Ok(())
}
pub(super) struct PtyCodex {
master: File,
child: Child,