diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 7413686a7..6668e2531 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -532,6 +532,9 @@ pub struct ModelAvailabilityNuxConfig { pub shown_count: HashMap, } +/// Fallback resize-reflow row cap when Codex cannot identify a terminal-specific scrollback size. +pub const DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS: usize = 1_000; + /// Collection of settings that are specific to the TUI. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] @@ -584,6 +587,13 @@ pub struct Tui { /// Startup tooltip availability NUX state persisted by the TUI. #[serde(default)] pub model_availability_nux: ModelAvailabilityNuxConfig, + + /// Trim terminal resize-reflow replay to the most recent rendered terminal rows when the + /// transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to + /// keep all rendered rows. + #[serde(default)] + #[schemars(range(min = 0))] + pub terminal_resize_reflow_max_rows: Option, } const fn default_true() -> bool { diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index dbc231690..3fbbfaf6e 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -526,6 +526,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, @@ -2252,6 +2255,13 @@ }, "type": "array" }, + "terminal_resize_reflow_max_rows": { + "default": null, + "description": "Trim terminal resize-reflow replay to the most recent rendered terminal rows when the transcript exceeds this cap. Omit to use Codex's terminal-specific default. Set to `0` to keep all rendered rows.", + "format": "uint", + "minimum": 0.0, + "type": "integer" + }, "terminal_title": { "default": null, "description": "Ordered list of terminal title item identifiers.\n\nWhen set, the TUI renders the selected items into the terminal window/tab title. When unset, the TUI defaults to: `spinner` and `project`.", @@ -2721,6 +2731,9 @@ "telepathy": { "type": "boolean" }, + "terminal_resize_reflow": { + "type": "boolean" + }, "tool_call_mcp_elicitation": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 37815411c..8462c0470 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -519,10 +519,28 @@ fn config_toml_deserializes_model_availability_nux() { ("gpt-foo".to_string(), 2), ]), }, + terminal_resize_reflow_max_rows: None, } ); } +#[test] +fn config_toml_deserializes_terminal_resize_reflow_config() { + let toml = r#" +[tui] +terminal_resize_reflow_max_rows = 9000 +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for resize reflow config"); + + assert_eq!( + cfg.tui + .expect("tui config should deserialize") + .terminal_resize_reflow_max_rows, + Some(9000) + ); +} + #[tokio::test] async fn runtime_config_defaults_model_availability_nux() { let cfg = Config::load_from_base_config_with_overrides( @@ -1388,10 +1406,69 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { terminal_title: None, theme: None, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow_max_rows: None, } ); } +#[tokio::test] +async fn runtime_config_resolves_terminal_resize_reflow_defaults_and_overrides() { + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load default config"); + + assert_eq!( + cfg.terminal_resize_reflow, + TerminalResizeReflowConfig::default() + ); + assert_eq!( + cfg.terminal_resize_reflow.max_rows, + TerminalResizeReflowMaxRows::Auto + ); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + terminal_resize_reflow_max_rows: Some(9000), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load overridden config"); + + assert_eq!( + cfg.terminal_resize_reflow.max_rows, + TerminalResizeReflowMaxRows::Limit(9000) + ); + + let cfg = Config::load_from_base_config_with_overrides( + ConfigToml { + tui: Some(Tui { + terminal_resize_reflow_max_rows: Some(0), + ..Default::default() + }), + ..Default::default() + }, + ConfigOverrides::default(), + tempdir().expect("tempdir").abs(), + ) + .await + .expect("load config with disabled resize reflow limits"); + + assert_eq!( + cfg.terminal_resize_reflow.max_rows, + TerminalResizeReflowMaxRows::Disabled + ); +} + #[tokio::test] async fn test_sandbox_config_parsing() { let sandbox_full_access = r#" @@ -5310,6 +5387,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -5506,6 +5584,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -5656,6 +5735,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(false), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), @@ -5791,6 +5871,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { animations: true, show_tooltips: true, model_availability_nux: ModelAvailabilityNuxConfig::default(), + terminal_resize_reflow: TerminalResizeReflowConfig::default(), analytics_enabled: Some(true), feedback_enabled: true, tool_suggest: ToolSuggestConfig::default(), diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 11ae66de0..70a4e4eef 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -394,6 +394,9 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Terminal resize-reflow tuning knobs. + pub terminal_resize_reflow: TerminalResizeReflowConfig, + /// The absolute 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. @@ -650,6 +653,22 @@ impl Default for MultiAgentV2Config { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TerminalResizeReflowMaxRows { + /// Use the runtime terminal detector to choose a scrollback-sized cap. + #[default] + Auto, + /// Keep all rendered transcript rows during resize reflow. + Disabled, + /// Keep at most this many rendered transcript rows during resize reflow. + Limit(usize), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct TerminalResizeReflowConfig { + pub max_rows: TerminalResizeReflowMaxRows, +} + impl AuthManagerConfig for Config { fn codex_home(&self) -> PathBuf { self.codex_home.to_path_buf() @@ -1525,6 +1544,20 @@ fn resolve_multi_agent_v2_config( } } +fn resolve_terminal_resize_reflow_config(config_toml: &ConfigToml) -> TerminalResizeReflowConfig { + let Some(tui) = config_toml.tui.as_ref() else { + return TerminalResizeReflowConfig::default(); + }; + + TerminalResizeReflowConfig { + max_rows: match tui.terminal_resize_reflow_max_rows { + Some(0) => TerminalResizeReflowMaxRows::Disabled, + Some(rows) => TerminalResizeReflowMaxRows::Limit(rows), + None => TerminalResizeReflowMaxRows::Auto, + }, + } +} + fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiAgentV2ConfigToml> { match features?.multi_agent_v2.as_ref()? { FeatureToml::Enabled(_) => None, @@ -1941,6 +1974,7 @@ impl Config { .unwrap_or(WebSearchMode::Cached); let web_search_config = resolve_web_search_config(&cfg, &config_profile); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); + let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = agent_roles::load_agent_roles(fs, &cfg, &config_layer_stack, &mut startup_warnings) @@ -2491,6 +2525,7 @@ impl Config { tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()), tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + terminal_resize_reflow, otel: { let t: OtelConfigToml = cfg.otel.unwrap_or_default(); let log_user_prompt = t.log_user_prompt.unwrap_or(false); diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 38c209df4..6a2a2bc71 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -91,6 +91,8 @@ pub enum Feature { UnifiedExec, /// Route shell tool execution through the zsh exec bridge. ShellZshFork, + /// Reflow transcript scrollback when the terminal is resized. + TerminalResizeReflow, /// Include the freeform apply_patch tool. ApplyPatchFreeform, /// Stream structured progress while apply_patch input is being generated. @@ -669,6 +671,16 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::Removed, default_enabled: false, }, + FeatureSpec { + id: Feature::TerminalResizeReflow, + key: "terminal_resize_reflow", + stage: Stage::Experimental { + name: "Terminal resize reflow", + menu_description: "Rebuild Codex-owned transcript scrollback when the terminal width changes.", + announcement: "", + }, + default_enabled: true, + }, FeatureSpec { id: Feature::WebSearchRequest, key: "web_search_request", diff --git a/codex-rs/features/src/tests.rs b/codex-rs/features/src/tests.rs index 8249198e3..e410159b7 100644 --- a/codex-rs/features/src/tests.rs +++ b/codex-rs/features/src/tests.rs @@ -32,7 +32,8 @@ fn default_enabled_features_are_stable() { for spec in crate::FEATURES { if spec.default_enabled { assert!( - matches!(spec.stage, Stage::Stable | Stage::Removed), + matches!(spec.stage, Stage::Stable | Stage::Removed) + || spec.id == Feature::TerminalResizeReflow, "feature `{}` is enabled by default but is not stable/removed ({:?})", spec.key, spec.stage @@ -112,6 +113,19 @@ fn request_permissions_tool_is_under_development() { assert_eq!(Feature::RequestPermissionsTool.default_enabled(), false); } +#[test] +fn terminal_resize_reflow_is_experimental_and_enabled_by_default() { + assert_eq!( + feature_for_key("terminal_resize_reflow"), + Some(Feature::TerminalResizeReflow) + ); + assert!(matches!( + Feature::TerminalResizeReflow.stage(), + Stage::Experimental { .. } + )); + assert_eq!(Feature::TerminalResizeReflow.default_enabled(), true); +} + #[test] fn tool_suggest_is_stable_and_enabled_by_default() { assert_eq!(Feature::ToolSuggest.stage(), Stage::Stable); diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index dbf0cc5da..77c1f5277 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -71,6 +71,7 @@ use crate::test_support::PathBufExt; use crate::test_support::test_path_buf; #[cfg(test)] use crate::test_support::test_path_display; +use crate::transcript_reflow::TranscriptReflowState; use crate::tui; use crate::tui::TuiEvent; use crate::update_action::UpdateAction; @@ -190,6 +191,7 @@ mod loaded_threads; mod pending_interactive_replay; mod platform_actions; mod replay_filter; +mod resize_reflow; mod session_lifecycle; mod side; mod startup_prompts; @@ -488,6 +490,11 @@ struct SessionSummary { resume_command: Option, } +#[derive(Debug, Default)] +struct InitialHistoryReplayBuffer { + retained_lines: VecDeque>, +} + pub(crate) struct App { model_catalog: Arc, pub(crate) session_telemetry: SessionTelemetry, @@ -509,6 +516,8 @@ pub(crate) struct App { pub(crate) overlay: Option, pub(crate) deferred_history_lines: Vec>, has_emitted_history_lines: bool, + transcript_reflow: TranscriptReflowState, + initial_history_replay_buffer: Option, pub(crate) enhanced_keys_supported: bool, @@ -894,6 +903,8 @@ impl App { overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, + transcript_reflow: TranscriptReflowState::default(), + initial_history_replay_buffer: None, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: status_line_invalid_items_warned.clone(), terminal_title_invalid_items_warned: terminal_title_invalid_items_warned.clone(), @@ -1086,7 +1097,10 @@ impl App { app_server: &mut AppServerSession, event: TuiEvent, ) -> Result { - if matches!(event, TuiEvent::Draw) { + let terminal_resize_reflow_enabled = self.terminal_resize_reflow_enabled(); + if terminal_resize_reflow_enabled && matches!(event, TuiEvent::Draw | TuiEvent::Resize) { + self.handle_draw_pre_render(tui)?; + } else if matches!(event, TuiEvent::Draw | TuiEvent::Resize) { let size = tui.terminal.size()?; if size != tui.terminal.last_known_screen_size { self.refresh_status_line(); @@ -1108,7 +1122,7 @@ impl App { let pasted = pasted.replace("\r", "\n"); self.chat_widget.handle_paste(pasted); } - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { if self.backtrack_render_pending { self.backtrack_render_pending = false; self.render_transcript_once(tui); @@ -1122,15 +1136,23 @@ impl App { } // Allow widgets to process any pending timers before rendering. self.chat_widget.pre_draw_tick(); - tui.draw( - self.chat_widget.desired_height(tui.terminal.size()?.width), - |frame| { + let desired_height = + self.chat_widget.desired_height(tui.terminal.size()?.width); + if terminal_resize_reflow_enabled { + tui.draw_with_resize_reflow(desired_height, |frame| { self.chat_widget.render(frame.area(), frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { frame.set_cursor_position((x, y)); } - }, - )?; + })?; + } else { + tui.draw(desired_height, |frame| { + self.chat_widget.render(frame.area(), frame.buffer); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); + } + })?; + } if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { self.chat_widget .set_external_editor_state(ExternalEditorState::Active); diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index 3515d3756..abf90bdaf 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -678,6 +678,28 @@ mod tests { Ok(()) } + #[tokio::test] + async fn refresh_in_memory_config_from_disk_updates_resize_reflow_config() -> Result<()> { + let mut app = make_test_app().await; + let codex_home = tempdir()?; + app.config.codex_home = codex_home.path().to_path_buf().abs(); + std::fs::write( + codex_home.path().join("config.toml"), + r#" +[tui] +terminal_resize_reflow_max_rows = 9000 +"#, + )?; + + app.refresh_in_memory_config_from_disk().await?; + + assert_eq!( + app.config.terminal_resize_reflow.max_rows, + crate::legacy_core::config::TerminalResizeReflowMaxRows::Limit(9000) + ); + Ok(()) + } + #[tokio::test] async fn rebuild_config_for_resume_or_fallback_uses_current_config_on_same_cwd_error() -> Result<()> { diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 71292ab93..7e096c6b9 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -3,6 +3,7 @@ //! This module contains the exhaustive `AppEvent` dispatcher and exit-mode handling. Large domain //! actions are delegated to focused app submodules so the central match remains the routing layer. +use super::resize_reflow::trailing_run_start; use super::*; const SHUTDOWN_FIRST_EXIT_TIMEOUT: Duration = Duration::from_secs(/*secs*/ 2); @@ -178,6 +179,9 @@ impl App { tui.frame_requester().schedule_frame(); } + AppEvent::BeginInitialHistoryReplayBuffer => { + self.begin_initial_history_replay_buffer(); + } AppEvent::InsertHistoryCell(cell) => { let cell: Arc = cell.into(); if let Some(Overlay::Transcript(t)) = &mut self.overlay { @@ -185,23 +189,82 @@ impl App { tui.frame_requester().schedule_frame(); } self.transcript_cells.push(cell.clone()); - let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width); - if !display.is_empty() { - // Only insert a separating blank line for new cells that are not - // part of an ongoing stream. Streaming continuations should not - // accrue extra blank lines between chunks. - if !cell.is_stream_continuation() { - if self.has_emitted_history_lines { - display.insert(0, Line::from("")); - } else { - self.has_emitted_history_lines = true; - } + if self.initial_history_replay_buffer.as_ref().is_some() { + self.insert_history_cell_lines_with_initial_replay_buffer( + tui, + cell.as_ref(), + tui.terminal.last_known_screen_size.width, + ); + } else { + self.insert_history_cell_lines( + tui, + cell.as_ref(), + tui.terminal.last_known_screen_size.width, + ); + } + } + AppEvent::EndInitialHistoryReplayBuffer => { + self.finish_initial_history_replay_buffer(tui); + } + AppEvent::ConsolidateAgentMessage { source, cwd } => { + if !self.terminal_resize_reflow_enabled() { + self.transcript_reflow.clear(); + return Ok(AppRunControl::Continue); + } + let end = self.transcript_cells.len(); + let start = + trailing_run_start::(&self.transcript_cells); + if start < end { + let consolidated: Arc = + Arc::new(history_cell::AgentMarkdownCell::new(source, &cwd)); + self.transcript_cells + .splice(start..end, std::iter::once(consolidated.clone())); + + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.consolidate_cells(start..end, consolidated.clone()); + tui.frame_requester().schedule_frame(); } - if self.overlay.is_some() { - self.deferred_history_lines.extend(display); - } else { - tui.insert_history_lines(display); + + self.maybe_finish_stream_reflow(tui)?; + } else { + self.maybe_finish_stream_reflow(tui)?; + } + } + AppEvent::ConsolidateProposedPlan(source) => { + if !self.terminal_resize_reflow_enabled() { + self.transcript_reflow.clear(); + return Ok(AppRunControl::Continue); + } + let end = self.transcript_cells.len(); + let start = trailing_run_start::( + &self.transcript_cells, + ); + let consolidated: Arc = + Arc::new(history_cell::new_proposed_plan(source, &self.config.cwd)); + + if start < end { + self.transcript_cells + .splice(start..end, std::iter::once(consolidated.clone())); + + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.consolidate_cells(start..end, consolidated.clone()); + tui.frame_requester().schedule_frame(); } + + self.finish_required_stream_reflow(tui)?; + } else { + self.transcript_cells.push(consolidated.clone()); + if let Some(Overlay::Transcript(t)) = &mut self.overlay { + t.insert_cell(consolidated.clone()); + tui.frame_requester().schedule_frame(); + } + self.insert_history_cell_lines( + tui, + consolidated.as_ref(), + tui.terminal.last_known_screen_size.width, + ); + + self.maybe_finish_stream_reflow(tui)?; } } AppEvent::ApplyThreadRollback { num_turns } => { diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index 703cc38c3..f6fec98f1 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -83,10 +83,16 @@ impl App { } pub(super) fn reset_app_ui_state_after_clear(&mut self) { + self.reset_transcript_state_after_clear(); + } + + pub(super) fn reset_transcript_state_after_clear(&mut self) { self.overlay = None; self.transcript_cells.clear(); self.deferred_history_lines.clear(); self.has_emitted_history_lines = false; + self.transcript_reflow.clear(); + self.initial_history_replay_buffer = None; self.backtrack = BacktrackState::default(); self.backtrack_render_pending = false; } diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs new file mode 100644 index 000000000..b2702f470 --- /dev/null +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -0,0 +1,482 @@ +//! Connects terminal resize events to source-backed transcript scrollback rebuilds. +//! +//! The app stores conversation history as `HistoryCell`s, but it also writes finalized history into +//! terminal scrollback for the normal chat view. When the terminal width changes, this module uses +//! the stored cells as source, clears the Codex-owned terminal history, and re-emits the transcript +//! for the new terminal size. +//! +//! Streaming output is the fragile part of this lifecycle. Active streams first appear as transient +//! stream cells, then consolidate into source-backed finalized cells. Resize work that happens +//! before consolidation is marked as stream-time work so consolidation can force one final rebuild +//! from the finalized source. +//! +//! The row cap is enforced while rendering from `HistoryCell` source, not after writing to the +//! terminal. Initial resume replay uses the same display-line buffering contract so large sessions +//! do not write more retained rows than resize replay would later be willing to rebuild. + +use std::collections::VecDeque; +use std::sync::Arc; +use std::time::Instant; + +use codex_features::Feature; +use color_eyre::eyre::Result; +use ratatui::text::Line; + +use super::App; +use super::InitialHistoryReplayBuffer; +use crate::history_cell; +use crate::history_cell::HistoryCell; +use crate::transcript_reflow::TRANSCRIPT_REFLOW_DEBOUNCE; +use crate::tui; + +struct ReflowCellDisplay { + lines: Vec>, + is_stream_continuation: bool, +} + +/// Rendered transcript lines ready to be replayed into terminal scrollback. +/// +/// This is intentionally line-oriented rather than cell-oriented because the terminal only accepts +/// already-wrapped rows. Callers should keep treating `transcript_cells` as the source of truth; the +/// rows here are a transient render product for a single terminal width. +pub(super) struct ReflowRenderResult { + pub(super) lines: Vec>, +} + +pub(super) fn trailing_run_start(transcript_cells: &[Arc]) -> usize { + let end = transcript_cells.len(); + let mut start = end; + + while start > 0 + && transcript_cells[start - 1].is_stream_continuation() + && transcript_cells[start - 1].as_any().is::() + { + start -= 1; + } + + if start > 0 + && transcript_cells[start - 1].as_any().is::() + && !transcript_cells[start - 1].is_stream_continuation() + { + start -= 1; + } + + start +} + +impl App { + pub(super) fn reset_history_emission_state(&mut self) { + self.has_emitted_history_lines = false; + self.deferred_history_lines.clear(); + } + + fn display_lines_for_history_insert( + &mut self, + cell: &dyn HistoryCell, + width: u16, + ) -> Vec> { + let mut display = cell.display_lines(width); + if !display.is_empty() && !cell.is_stream_continuation() { + if self.has_emitted_history_lines { + display.insert(0, Line::from("")); + } else { + self.has_emitted_history_lines = true; + } + } + display + } + + pub(super) fn insert_history_cell_lines( + &mut self, + tui: &mut tui::Tui, + cell: &dyn HistoryCell, + width: u16, + ) { + let display = self.display_lines_for_history_insert(cell, width); + if display.is_empty() { + return; + } + if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + + pub(super) fn terminal_resize_reflow_enabled(&self) -> bool { + self.config.features.enabled(Feature::TerminalResizeReflow) + } + + /// Start retaining initial resume replay rows before they are written to scrollback. + /// + /// Resume replay can insert thousands of already-finalized history cells before the first draw. + /// When resize reflow is enabled, buffering here lets the same row cap used by resize rebuilds + /// apply to the startup write. Starting this buffer while an overlay owns rendering would split + /// transcript ownership, so overlay replay continues through the normal deferred-history path. + pub(super) fn begin_initial_history_replay_buffer(&mut self) { + if self.terminal_resize_reflow_enabled() && self.overlay.is_none() { + self.initial_history_replay_buffer = Some(Default::default()); + } + } + + /// Flush retained initial resume replay rows into terminal scrollback. + /// + /// The buffer stores display lines, not cells, because the cap is measured in terminal rows. + /// This mirrors terminal scrollback behavior and avoids making startup replay cheaper or more + /// expensive than a later resize rebuild of the same transcript. + pub(super) fn finish_initial_history_replay_buffer(&mut self, tui: &mut tui::Tui) { + let Some(buffer) = self.initial_history_replay_buffer.take() else { + return; + }; + + if buffer.retained_lines.is_empty() { + return; + } + + let retained_lines = buffer.retained_lines.into_iter().collect::>(); + tui.insert_history_lines(retained_lines); + } + + pub(super) fn insert_history_cell_lines_with_initial_replay_buffer( + &mut self, + tui: &mut tui::Tui, + cell: &dyn HistoryCell, + width: u16, + ) { + let display = self.display_lines_for_history_insert(cell, width); + + if display.is_empty() { + return; + } + + let max_rows = self.resize_reflow_max_rows(); + if let Some(buffer) = &mut self.initial_history_replay_buffer { + if let Some(max_rows) = max_rows { + Self::buffer_initial_history_replay_display_lines(buffer, display, max_rows); + } else if self.overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + } + + /// Retain only the newest rendered rows for initial resume replay. + /// + /// The oldest rows are dropped first because terminal scrollback caps preserve the tail of the + /// transcript. Keeping this policy local to display lines is important: trimming source cells + /// here would make copy, transcript overlay, and future replay paths disagree about history. + pub(super) fn buffer_initial_history_replay_display_lines( + buffer: &mut InitialHistoryReplayBuffer, + display: Vec>, + max_rows: usize, + ) { + buffer.retained_lines.extend(display); + while buffer.retained_lines.len() > max_rows { + buffer.retained_lines.pop_front(); + } + } + + fn schedule_resize_reflow(&mut self, target_width: Option) -> bool { + debug_assert!(self.terminal_resize_reflow_enabled()); + self.transcript_reflow.schedule_debounced(target_width) + } + + fn resize_reflow_max_rows(&self) -> Option { + crate::resize_reflow_cap::resize_reflow_max_rows(self.config.terminal_resize_reflow) + } + + fn clear_terminal_for_resize_replay(&mut self, tui: &mut tui::Tui) -> Result<()> { + if tui.is_alt_screen_active() { + tui.terminal.clear_visible_screen()?; + } else { + tui.terminal.clear_scrollback_and_visible_screen_ansi()?; + } + let mut area = tui.terminal.viewport_area; + if area.y > 0 { + area.y = 0; + tui.terminal.set_viewport_area(area); + } + Ok(()) + } + + /// Finish stream consolidation by repairing any resize work that happened during streaming. + /// + /// This is called after agent-message stream cells have either been replaced by an + /// `AgentMarkdownCell` or found to need no replacement. If a resize happened while the stream + /// was active or while its transient cells were still present, this method runs an immediate + /// source-backed reflow so terminal scrollback reflects the finalized cell instead of the + /// transient stream rows. + pub(super) fn maybe_finish_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { + if !self.terminal_resize_reflow_enabled() { + self.transcript_reflow.clear(); + return Ok(()); + } + + if self.transcript_reflow.take_stream_finish_reflow_needed() { + self.schedule_immediate_resize_reflow(tui); + self.maybe_run_resize_reflow(tui)?; + } else if self.transcript_reflow.pending_is_due(Instant::now()) { + tui.frame_requester().schedule_frame(); + } + Ok(()) + } + + fn schedule_immediate_resize_reflow(&mut self, tui: &mut tui::Tui) { + if !self.terminal_resize_reflow_enabled() { + self.transcript_reflow.clear(); + return; + } + self.transcript_reflow.schedule_immediate(); + tui.frame_requester().schedule_frame(); + } + + /// Force stream-finalized output through the resize reflow path. + /// + /// Proposed plan consolidation uses this stricter path because a completed plan is inserted or + /// replaced as one styled source-backed cell. If this reflow is skipped after a stream-time + /// resize, the visible scrollback can keep the pre-consolidation wrapping. + pub(super) fn finish_required_stream_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { + if !self.terminal_resize_reflow_enabled() { + self.transcript_reflow.clear(); + return Ok(()); + } + self.schedule_immediate_resize_reflow(tui); + self.maybe_run_resize_reflow(tui)?; + if !self.transcript_reflow.has_pending_reflow() { + self.transcript_reflow.clear_stream_flags(); + } + Ok(()) + } + + /// Record terminal size changes and schedule any resize-sensitive transcript work. + /// + /// Width changes need a rebuild because transcript wrapping changes. Height changes can expose, + /// hide, or shift rows around the inline viewport, so they also rebuild from source-backed + /// cells. The first observed width initializes resize tracking without scheduling a rebuild, + /// because there is no previously emitted width to repair yet. + pub(super) fn handle_draw_size_change( + &mut self, + size: ratatui::layout::Size, + last_known_screen_size: ratatui::layout::Size, + frame_requester: &tui::FrameRequester, + ) -> bool { + let width = self.transcript_reflow.note_width(size.width); + let reflow_needed = self.transcript_reflow.reflow_needed_for_width(size.width); + let height_changed = size.height != last_known_screen_size.height; + let should_rebuild_transcript = reflow_needed || height_changed; + if width.changed || width.initialized { + self.chat_widget.on_terminal_resize(size.width); + } + if should_rebuild_transcript { + if self.terminal_resize_reflow_enabled() { + if reflow_needed && self.should_mark_reflow_as_stream_time() { + self.transcript_reflow.mark_resize_requested_during_stream(); + } + let target_width = reflow_needed.then_some(size.width); + if self.schedule_resize_reflow(target_width) { + frame_requester.schedule_frame(); + } else { + frame_requester.schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE); + } + } else if !self.terminal_resize_reflow_enabled() && width.changed { + self.transcript_reflow.clear(); + } + } + if size != last_known_screen_size { + self.refresh_status_line(); + } + if self.terminal_resize_reflow_enabled() { + self.maybe_clear_resize_reflow_without_terminal(); + } + should_rebuild_transcript + } + + fn maybe_clear_resize_reflow_without_terminal(&mut self) { + if !self.terminal_resize_reflow_enabled() { + self.transcript_reflow.clear(); + return; + } + let Some(deadline) = self.transcript_reflow.pending_until() else { + return; + }; + if Instant::now() < deadline || self.overlay.is_some() || !self.transcript_cells.is_empty() + { + return; + } + + self.transcript_reflow.clear_pending_reflow(); + self.reset_history_emission_state(); + } + + pub(super) fn handle_draw_pre_render(&mut self, tui: &mut tui::Tui) -> Result<()> { + let size = tui.terminal.size()?; + let should_rebuild_transcript = self.handle_draw_size_change( + size, + tui.terminal.last_known_screen_size, + &tui.frame_requester(), + ); + if should_rebuild_transcript && self.terminal_resize_reflow_enabled() { + // Resize-sensitive history inserts queued before this frame may be wrapped for the old + // viewport or targeted at rows no longer visible. Drop them and let resize reflow + // rebuild from transcript cells. + tui.clear_pending_history_lines(); + } + self.maybe_run_resize_reflow(tui)?; + Ok(()) + } + + /// Run a pending transcript reflow when its debounce deadline has arrived. + /// + /// Reflow is deferred while an overlay is active because the overlay owns the current draw + /// surface. Callers must keep using `HistoryCell` source as the rebuild input; attempting to + /// reuse terminal-wrapped output here would preserve exactly the stale wrapping this feature is + /// meant to remove. + pub(super) fn maybe_run_resize_reflow(&mut self, tui: &mut tui::Tui) -> Result<()> { + if !self.terminal_resize_reflow_enabled() { + self.transcript_reflow.clear(); + return Ok(()); + } + let Some(deadline) = self.transcript_reflow.pending_until() else { + return Ok(()); + }; + let now = Instant::now(); + if now < deadline { + // Later resize events push the reflow deadline out, while the frame scheduler coalesces + // delayed draws to the earliest requested instant. If an early draw arrives before the + // latest quiet-period deadline, re-arm the draw so the pending reflow cannot get stuck + // until the next keypress. + tui.frame_requester().schedule_frame_in(deadline - now); + return Ok(()); + } + if self.overlay.is_some() { + return Ok(()); + } + + self.transcript_reflow.clear_pending_reflow(); + + // Track that a reflow happened during an active stream or while trailing + // unconsolidated AgentMessageCells are still pending consolidation so + // ConsolidateAgentMessage can schedule a follow-up reflow. + let reflow_ran_during_stream = + !self.transcript_cells.is_empty() && self.should_mark_reflow_as_stream_time(); + + let width = self.reflow_transcript_now(tui)?; + self.transcript_reflow.mark_reflowed_width(width); + + if reflow_ran_during_stream { + self.transcript_reflow.mark_ran_during_stream(); + } + // Some terminals settle their final reported width after the repaint that handled the + // last resize event. Request one cheap follow-up draw so `handle_draw_pre_render` can + // sample that width and schedule a final reflow if needed. + tui.frame_requester() + .schedule_frame_in(TRANSCRIPT_REFLOW_DEBOUNCE); + + Ok(()) + } + + fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result { + let width = tui.terminal.size()?.width; + if self.transcript_cells.is_empty() { + // Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells. + tui.clear_pending_history_lines(); + self.reset_history_emission_state(); + return Ok(width); + } + + let reflow_result = self.render_transcript_lines_for_reflow(width); + let reflowed_lines = reflow_result.lines; + + // Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells. + tui.clear_pending_history_lines(); + self.clear_terminal_for_resize_replay(tui)?; + + self.deferred_history_lines.clear(); + if !reflowed_lines.is_empty() { + tui.insert_history_lines(reflowed_lines); + } + + Ok(width) + } + + /// Render transcript cells for the current resize rebuild. + /// + /// Rendering walks backward from the transcript tail so row-capped sessions avoid formatting the + /// full backlog. If the retained suffix begins inside a stream-continuation run, the walk extends + /// to include the run's first cell; otherwise separators would be inserted as if the continuation + /// were a new top-level history item. The final row trim happens after separators are restored, + /// so the returned rows obey the cap exactly. + pub(super) fn render_transcript_lines_for_reflow(&mut self, width: u16) -> ReflowRenderResult { + let row_cap = self.resize_reflow_max_rows(); + let mut cell_displays = VecDeque::new(); + let mut rendered_rows = 0usize; + let mut start = self.transcript_cells.len(); + + while start > 0 { + start -= 1; + let cell = self.transcript_cells[start].clone(); + let lines = cell.display_lines(width); + rendered_rows += lines.len(); + cell_displays.push_front(ReflowCellDisplay { + lines, + is_stream_continuation: cell.is_stream_continuation(), + }); + + if row_cap.is_some_and(|max_rows| rendered_rows > max_rows) { + break; + } + } + + while start > 0 + && cell_displays + .front() + .is_some_and(|display| display.is_stream_continuation) + { + start -= 1; + let cell = self.transcript_cells[start].clone(); + cell_displays.push_front(ReflowCellDisplay { + lines: cell.display_lines(width), + is_stream_continuation: cell.is_stream_continuation(), + }); + } + + let mut has_emitted_history_lines = false; + let mut reflowed_lines = Vec::new(); + for display in cell_displays { + if !display.lines.is_empty() && !display.is_stream_continuation { + if has_emitted_history_lines { + reflowed_lines.push(Line::from("")); + } else { + has_emitted_history_lines = true; + } + } + reflowed_lines.extend(display.lines); + } + if let Some(max_rows) = row_cap + && reflowed_lines.len() > max_rows + { + let trimmed_line_count = reflowed_lines.len() - max_rows; + reflowed_lines = reflowed_lines.split_off(trimmed_line_count); + } + self.has_emitted_history_lines = !reflowed_lines.is_empty(); + + ReflowRenderResult { + lines: reflowed_lines, + } + } + + /// Return whether current transcript state should be treated as stream-time resize state. + /// + /// The active stream controllers cover normal streaming. The trailing-cell checks cover the + /// narrow window after a controller has stopped but before the app has processed the + /// consolidation event that replaces transient stream cells with source-backed cells. + pub(super) fn should_mark_reflow_as_stream_time(&self) -> bool { + self.chat_widget.has_active_agent_stream() + || self.chat_widget.has_active_plan_stream() + || trailing_run_start::(&self.transcript_cells) + < self.transcript_cells.len() + || trailing_run_start::(&self.transcript_cells) + < self.transcript_cells.len() + } +} diff --git a/codex-rs/tui/src/app/session_lifecycle.rs b/codex-rs/tui/src/app/session_lifecycle.rs index dddae35e0..c51863bd1 100644 --- a/codex-rs/tui/src/app/session_lifecycle.rs +++ b/codex-rs/tui/src/app/session_lifecycle.rs @@ -385,13 +385,8 @@ impl App { } pub(super) fn reset_for_thread_switch(&mut self, tui: &mut tui::Tui) -> Result<()> { - self.overlay = None; - self.transcript_cells.clear(); - self.deferred_history_lines.clear(); + self.reset_transcript_state_after_clear(); tui.clear_pending_history_lines(); - self.has_emitted_history_lines = false; - self.backtrack = BacktrackState::default(); - self.backtrack_render_pending = false; Self::clear_terminal_for_thread_switch(&mut tui.terminal)?; Ok(()) } diff --git a/codex-rs/tui/src/app/test_support.rs b/codex-rs/tui/src/app/test_support.rs index 4dc724ee5..29b7dede0 100644 --- a/codex-rs/tui/src/app/test_support.rs +++ b/codex-rs/tui/src/app/test_support.rs @@ -30,6 +30,8 @@ pub(super) async fn make_test_app() -> App { overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, + transcript_reflow: TranscriptReflowState::default(), + initial_history_replay_buffer: None, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index e40f18c65..550dcff80 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -15,6 +15,7 @@ use crate::chatwidget::tests::set_fast_mode_test_catalog; use crate::file_search::FileSearchManager; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; +use crate::history_cell::PlainHistoryCell; use crate::history_cell::UserHistoryCell; use crate::history_cell::new_session_info; use crate::multi_agents::AgentPickerThreadEntry; @@ -22,6 +23,7 @@ use assert_matches::assert_matches; use crate::legacy_core::config::ConfigBuilder; use crate::legacy_core::config::ConfigOverrides; +use crate::legacy_core::config::TerminalResizeReflowMaxRows; use codex_app_server_protocol::AdditionalFileSystemPermissions; use codex_app_server_protocol::AdditionalNetworkPermissions; use codex_app_server_protocol::AdditionalPermissionProfile; @@ -3645,6 +3647,8 @@ async fn make_test_app() -> App { overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, + transcript_reflow: TranscriptReflowState::default(), + initial_history_replay_buffer: None, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -3702,6 +3706,8 @@ async fn make_test_app_with_channels() -> ( overlay: None, deferred_history_lines: Vec::new(), has_emitted_history_lines: false, + transcript_reflow: TranscriptReflowState::default(), + initial_history_replay_buffer: None, enhanced_keys_supported: false, commit_anim_running: Arc::new(AtomicBool::new(false)), status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)), @@ -3759,6 +3765,147 @@ fn test_thread_session(thread_id: ThreadId, cwd: PathBuf) -> ThreadSessionState } } +fn enable_terminal_resize_reflow(app: &mut App) { + app.config + .features + .set_enabled(Feature::TerminalResizeReflow, /*enabled*/ true) + .expect("feature should be configurable"); +} + +fn plain_line_cell(text: impl Into) -> Arc { + Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc +} + +fn rendered_line_text(line: &Line<'static>) -> String { + line.spans + .iter() + .map(|span| span.content.as_ref()) + .collect() +} + +#[tokio::test] +async fn capped_resize_reflow_renders_recent_suffix_only() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(5); + app.transcript_cells = (0..20) + .map(|i| plain_line_cell(format!("cell {i}"))) + .collect(); + + let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); + + assert_eq!(rendered.lines.len(), 5); + assert_eq!( + rendered + .lines + .iter() + .map(rendered_line_text) + .collect::>(), + vec![ + "cell 17".to_string(), + String::new(), + "cell 18".to_string(), + String::new(), + "cell 19".to_string(), + ] + ); +} + +#[tokio::test] +async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.transcript_cells = (0..20) + .map(|i| plain_line_cell(format!("cell {i}"))) + .collect(); + + let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); + + assert_eq!(rendered.lines.len(), 39); + assert_eq!(rendered_line_text(&rendered.lines[0]), "cell 0"); + assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19"); +} + +#[tokio::test] +async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(100); + app.transcript_cells = (0..3) + .map(|i| plain_line_cell(format!("cell {i}"))) + .collect(); + + let rendered = app.render_transcript_lines_for_reflow(/*width*/ 80); + + assert_eq!( + rendered + .lines + .iter() + .map(rendered_line_text) + .collect::>(), + vec![ + "cell 0".to_string(), + String::new(), + "cell 1".to_string(), + String::new(), + "cell 2".to_string(), + ] + ); +} + +#[tokio::test] +async fn initial_replay_buffer_keeps_recent_rows_when_row_cap_present() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + enable_terminal_resize_reflow(&mut app); + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Limit(3); + + app.begin_initial_history_replay_buffer(); + for index in 0..5 { + App::buffer_initial_history_replay_display_lines( + app.initial_history_replay_buffer + .as_mut() + .expect("initial replay buffer active"), + vec![Line::from(format!("line {index}"))], + /*max_rows*/ 3, + ); + } + + let buffer = app + .initial_history_replay_buffer + .as_ref() + .expect("initial replay buffer should remain active"); + assert_eq!( + buffer + .retained_lines + .iter() + .map(rendered_line_text) + .collect::>(), + vec![ + "line 2".to_string(), + "line 3".to_string(), + "line 4".to_string(), + ] + ); +} + +#[tokio::test] +async fn height_shrink_schedules_resize_reflow() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + enable_terminal_resize_reflow(&mut app); + let frame_requester = crate::tui::FrameRequester::test_dummy(); + + assert!(!app.handle_draw_size_change( + ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), + ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), + &frame_requester, + )); + + assert!(app.handle_draw_size_change( + ratatui::layout::Size::new(/*width*/ 118, /*height*/ 24), + ratatui::layout::Size::new(/*width*/ 118, /*height*/ 35), + &frame_requester, + )); + assert!(app.transcript_reflow.has_pending_reflow()); +} + fn test_turn(turn_id: &str, status: TurnStatus, items: Vec) -> Turn { Turn { id: turn_id.to_string(), diff --git a/codex-rs/tui/src/app/thread_routing.rs b/codex-rs/tui/src/app/thread_routing.rs index bf1f95555..5f0f52c2c 100644 --- a/codex-rs/tui/src/app/thread_routing.rs +++ b/codex-rs/tui/src/app/thread_routing.rs @@ -671,6 +671,7 @@ impl App { } AppCommandView::ReloadUserConfig => { app_server.reload_user_config().await?; + self.refresh_in_memory_config_from_disk().await?; Ok(true) } AppCommandView::OverrideTurnContext { .. } => Ok(true), @@ -1036,8 +1037,18 @@ impl App { self.chat_widget .set_initial_user_message_submit_suppressed(/*suppressed*/ true); self.chat_widget.handle_thread_session(session); + let should_buffer_initial_replay = + self.terminal_resize_reflow_enabled() && !turns.is_empty(); + if should_buffer_initial_replay { + self.app_event_tx + .send(AppEvent::BeginInitialHistoryReplayBuffer); + } self.chat_widget .replay_thread_turns(turns, ReplayKind::ResumeInitialMessages); + if should_buffer_initial_replay { + self.app_event_tx + .send(AppEvent::EndInitialHistoryReplayBuffer); + } let pending = std::mem::take(&mut self.pending_primary_events); for pending_event in pending { match pending_event { diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index cc99a791d..da1f82e62 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -363,7 +363,7 @@ impl App { /// source of truth for the active cell and its cache invalidation key, and because `App` owns /// overlay lifecycle and frame scheduling for animations. fn overlay_forward_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { - if let TuiEvent::Draw = &event + if matches!(&event, TuiEvent::Draw | TuiEvent::Resize) && let Some(Overlay::Transcript(t)) = &mut self.overlay { let active_key = self.chat_widget.active_cell_transcript_key(); diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fa3549e6a..7df90020e 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -384,8 +384,34 @@ pub(crate) enum AppEvent { result: Result, }, + /// Begin buffering initial resume replay rows before they are written to scrollback. + BeginInitialHistoryReplayBuffer, + InsertHistoryCell(Box), + /// Finish buffering initial resume replay after all replay events have been queued. + EndInitialHistoryReplayBuffer, + + /// Replace the contiguous run of streaming `AgentMessageCell`s at the end of + /// the transcript with a single `AgentMarkdownCell` that stores the raw + /// markdown source and re-renders from it on resize. + /// + /// Emitted by `ChatWidget::flush_answer_stream_with_separator` after stream + /// finalization. The `App` handler walks backward through `transcript_cells` + /// to find the `AgentMessageCell` run and splices in the consolidated cell. + /// The `cwd` keeps local file-link display stable across the final re-render. + ConsolidateAgentMessage { + source: String, + cwd: PathBuf, + }, + + /// Replace the contiguous run of streaming `ProposedPlanStreamCell`s at the + /// end of the transcript with a single source-backed `ProposedPlanCell`. + /// + /// Emitted by `ChatWidget::on_plan_item_completed` after plan stream + /// finalization. + ConsolidateProposedPlan(String), + /// Apply rollback semantics to local transcript cells. /// /// This is emitted when rollback was not initiated by the current diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4eb4bd0cc..6d2450ece 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2034,12 +2034,25 @@ impl ChatWidget { } fn flush_answer_stream_with_separator(&mut self) { - if let Some(mut controller) = self.stream_controller.take() - && let Some(cell) = controller.finalize() - { - self.add_boxed_history(cell); + let had_stream_controller = self.stream_controller.is_some(); + if let Some(mut controller) = self.stream_controller.take() { + let (cell, source) = controller.finalize(); + if let Some(cell) = cell { + self.add_boxed_history(cell); + } + // Consolidate the run of streaming AgentMessageCells into a single AgentMarkdownCell + // that can re-render from source on resize. + if let Some(source) = source { + self.app_event_tx.send(AppEvent::ConsolidateAgentMessage { + source, + cwd: self.config.cwd.to_path_buf(), + }); + } } self.adaptive_chunking.reset(); + if had_stream_controller && self.stream_controllers_idle() { + self.app_event_tx.send(AppEvent::StopCommitAnimation); + } } fn stream_controllers_idle(&self) -> bool { @@ -2626,7 +2639,7 @@ impl ChatWidget { if self.plan_stream_controller.is_none() { self.plan_stream_controller = Some(PlanStreamController::new( - self.last_rendered_width.get().map(|w| w.saturating_sub(4)), + self.current_stream_width(/*reserved_cols*/ 4), &self.config.cwd, )); } @@ -2656,18 +2669,25 @@ impl ChatWidget { self.plan_delta_buffer.clear(); self.plan_item_active = false; self.saw_plan_item_this_turn = true; - let finalized_streamed_cell = + let (finalized_streamed_cell, consolidated_plan_source) = if let Some(mut controller) = self.plan_stream_controller.take() { controller.finalize() } else { - None + (None, None) }; if let Some(cell) = finalized_streamed_cell { self.add_boxed_history(cell); // TODO: Replace streamed output with the final plan item text if plan streaming is // removed or if we need to reconcile mismatches between streamed and final content. + if let Some(source) = consolidated_plan_source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } } else if !plan_text.is_empty() { self.add_to_history(history_cell::new_proposed_plan(plan_text, &self.config.cwd)); + } else if let Some(source) = consolidated_plan_source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); } if should_restore_after_stream { self.pending_status_indicator_restore = true; @@ -2785,10 +2805,15 @@ impl ChatWidget { self.saw_copy_source_this_turn = false; // If a stream is currently active, finalize it. self.flush_answer_stream_with_separator(); - if let Some(mut controller) = self.plan_stream_controller.take() - && let Some(cell) = controller.finalize() - { - self.add_boxed_history(cell); + if let Some(mut controller) = self.plan_stream_controller.take() { + let (cell, source) = controller.finalize(); + if let Some(cell) = cell { + self.add_boxed_history(cell); + } + if let Some(source) = source { + self.app_event_tx + .send(AppEvent::ConsolidateProposedPlan(source)); + } } self.flush_unified_exec_wait_streak(); if !from_replay { @@ -5042,7 +5067,7 @@ impl ChatWidget { self.needs_final_message_separator = false; } self.stream_controller = Some(StreamController::new( - self.last_rendered_width.get().map(|w| w.saturating_sub(2)), + self.current_stream_width(/*reserved_cols*/ 2), &self.config.cwd, )); } @@ -11377,6 +11402,52 @@ impl ChatWidget { self.bottom_pane.is_task_running() || self.is_review_mode } + /// Return the markdown body width available to an active stream. + /// + /// Streaming controllers render only the message body, while history cells add bullets, + /// gutters, or plan padding around that body. Callers pass the reserved columns for that + /// wrapper so live output uses the same width that finalized cells will use during reflow. + fn current_stream_width(&self, reserved_cols: usize) -> Option { + self.last_rendered_width.get().and_then(|width| { + if width == 0 { + None + } else { + Some(crate::width::usable_content_width(width, reserved_cols).unwrap_or(1)) + } + }) + } + + /// Update resize-sensitive chat widget state after the terminal width changes. + /// + /// The app calls this even when terminal resize reflow is disabled so live stream wrapping + /// remains consistent with the current viewport. Finalized transcript rebuilding stays gated at + /// the app layer. + pub(crate) fn on_terminal_resize(&mut self, width: u16) { + let had_rendered_width = self.last_rendered_width.get().is_some(); + self.last_rendered_width.set(Some(width as usize)); + let stream_width = self.current_stream_width(/*reserved_cols*/ 2); + let plan_stream_width = self.current_stream_width(/*reserved_cols*/ 4); + if let Some(controller) = self.stream_controller.as_mut() { + controller.set_width(stream_width); + } + if let Some(controller) = self.plan_stream_controller.as_mut() { + controller.set_width(plan_stream_width); + } + if !had_rendered_width { + self.request_redraw(); + } + } + + /// Whether an agent message stream is active (not a plan stream). + pub(crate) fn has_active_agent_stream(&self) -> bool { + self.stream_controller.is_some() + } + + /// Whether a proposed-plan stream is active. + pub(crate) fn has_active_plan_stream(&self) -> bool { + self.plan_stream_controller.is_some() + } + fn is_plan_streaming_in_tui(&self) -> bool { self.plan_stream_controller.is_some() } @@ -11503,6 +11574,7 @@ impl ChatWidget { T: Into, { let op: AppCommand = op.into(); + self.prepare_local_op_submission(&op); if op.is_review() && !self.bottom_pane.is_task_running() { self.bottom_pane.set_task_running(/*running*/ true); } @@ -11521,6 +11593,20 @@ impl ChatWidget { true } + pub(crate) fn prepare_local_op_submission(&mut self, op: &AppCommand) { + if matches!(op.view(), crate::app_command::AppCommandView::Interrupt) + && self.agent_turn_running + { + if let Some(controller) = self.stream_controller.as_mut() { + controller.clear_queue(); + } + if let Some(controller) = self.plan_stream_controller.as_mut() { + controller.clear_queue(); + } + self.request_redraw(); + } + } + #[cfg(test)] fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { self.add_to_history(history_cell::new_mcp_tools_output( @@ -11652,6 +11738,7 @@ impl ChatWidget { self.config.config_layer_stack = config.config_layer_stack.clone(); self.config.realtime = config.realtime.clone(); self.config.memories = config.memories.clone(); + self.config.terminal_resize_reflow = config.terminal_resize_reflow; } pub(crate) fn open_review_popup(&mut self) { diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 556992b80..cadf1fa13 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -416,8 +416,12 @@ where if self.viewport_area.is_empty() { return Ok(()); } - self.backend - .set_cursor_position(self.viewport_area.as_position())?; + self.clear_after_position(self.viewport_area.as_position()) + } + + /// Clear from `position` through the end of the visible screen and force a full redraw. + pub(crate) fn clear_after_position(&mut self, position: Position) -> io::Result<()> { + self.backend.set_cursor_position(position)?; self.backend.clear_region(ClearType::AfterCursor)?; // Reset the back buffer to make sure the next update will redraw everything. self.previous_buffer_mut().reset(); diff --git a/codex-rs/tui/src/cwd_prompt.rs b/codex-rs/tui/src/cwd_prompt.rs index 0dace9c7b..264fa39c7 100644 --- a/codex-rs/tui/src/cwd_prompt.rs +++ b/codex-rs/tui/src/cwd_prompt.rs @@ -97,7 +97,7 @@ pub(crate) async fn run_cwd_selection_prompt( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); })?; diff --git a/codex-rs/tui/src/external_agent_config_migration.rs b/codex-rs/tui/src/external_agent_config_migration.rs index ecc2f75b4..0e709f945 100644 --- a/codex-rs/tui/src/external_agent_config_migration.rs +++ b/codex-rs/tui/src/external_agent_config_migration.rs @@ -117,7 +117,7 @@ pub(crate) async fn run_external_agent_config_migration_prompt( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { let _ = tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); }); diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index b6806f88f..16c2440de 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -79,6 +79,7 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Styled; use ratatui::style::Stylize; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; use ratatui::widgets::Wrap; use std::any::Any; @@ -99,9 +100,6 @@ pub(crate) use hook_cell::HookCell; pub(crate) use hook_cell::new_active_hook_cell; pub(crate) use hook_cell::new_completed_hook_cell; -/// Represents an event to display in the conversation history. Returns its -/// `Vec>` representation to make it easier to display in a -/// scrollable list. /// A single renderable unit of conversation history. /// /// Each cell produces logical `Line`s and reports how many viewport @@ -195,6 +193,9 @@ impl Renderable for Box { .saturating_sub(usize::from(area.height)); u16::try_from(overflow).unwrap_or(u16::MAX) }; + // Active-cell content can reflow dramatically during resize/stream updates. Clear the + // entire draw area first so stale glyphs from previous frames never linger. + Clear.render(area, buf); paragraph.scroll((y, 0)).render(area, buf); } fn desired_height(&self, width: u16) -> u16 { @@ -412,7 +413,7 @@ impl ReasoningSummaryCell { let mut lines: Vec> = Vec::new(); append_markdown( &self.content, - Some((width as usize).saturating_sub(2)), + crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2), Some(self.cwd.as_path()), &mut lines, ); @@ -486,6 +487,57 @@ impl HistoryCell for AgentMessageCell { } } +/// A consolidated agent message cell that stores raw markdown source and re-renders from it. +/// +/// After a stream finalizes, the `ConsolidateAgentMessage` handler in `App` +/// replaces the contiguous run of `AgentMessageCell`s with a single +/// `AgentMarkdownCell`. On terminal resize, `display_lines(width)` re-renders +/// from source via `append_markdown`. +/// +/// The cell snapshots `cwd` at construction so local file-link display remains aligned with the +/// session that produced the message. Reusing the current process cwd during reflow would make old +/// transcript content change meaning after a later `/cd` or resumed session. +#[derive(Debug)] +pub(crate) struct AgentMarkdownCell { + markdown_source: String, + cwd: PathBuf, +} + +impl AgentMarkdownCell { + /// Create a finalized source-backed assistant message cell. + /// + /// `markdown_source` must be the raw source accumulated by the stream controller, not already + /// wrapped terminal lines. Passing rendered lines here would make future resize reflow preserve + /// stale wrapping instead of repairing it. + pub(crate) fn new(markdown_source: String, cwd: &Path) -> Self { + Self { + markdown_source, + cwd: cwd.to_path_buf(), + } + } +} + +impl HistoryCell for AgentMarkdownCell { + fn display_lines(&self, width: u16) -> Vec> { + let Some(wrap_width) = + crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2) + else { + return prefix_lines(vec![Line::default()], "• ".dim(), " ".into()); + }; + + let mut lines: Vec> = Vec::new(); + // Re-render markdown from source at the current width. Reserve 2 columns for the "• " / + // " " prefix prepended below. + crate::markdown::append_markdown( + &self.markdown_source, + Some(wrap_width), + Some(self.cwd.as_path()), + &mut lines, + ); + prefix_lines(lines, "• ".dim(), " ".into()) + } +} + #[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, @@ -2497,6 +2549,10 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlanUpdateCell { } /// Create a proposed-plan cell that snapshots the session cwd for later markdown rendering. +/// +/// The plan body is stored as raw markdown so terminal resize reflow can render it again at the +/// current width. Callers should use `new_proposed_plan_stream` only for transient live streaming +/// cells, then consolidate to this source-backed cell when the plan is complete. pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPlanCell { ProposedPlanCell { plan_markdown, @@ -2504,6 +2560,10 @@ pub(crate) fn new_proposed_plan(plan_markdown: String, cwd: &Path) -> ProposedPl } } +/// Create a transient proposed-plan stream cell from already rendered lines. +/// +/// Stream cells are display fragments, not source-backed history. They should be replaced by +/// `ProposedPlanCell` during consolidation before relying on resize reflow for finalized history. pub(crate) fn new_proposed_plan_stream( lines: Vec>, is_stream_continuation: bool, @@ -2514,6 +2574,10 @@ pub(crate) fn new_proposed_plan_stream( } } +/// Finalized proposed-plan history that can render itself again for a new width. +/// +/// This is the source-backed counterpart to `ProposedPlanStreamCell`. It owns raw markdown and the +/// session cwd needed for stable local-link rendering during later transcript reflow. #[derive(Debug)] pub(crate) struct ProposedPlanCell { plan_markdown: String, @@ -2521,6 +2585,11 @@ pub(crate) struct ProposedPlanCell { cwd: PathBuf, } +/// Transient proposed-plan history emitted while a plan is still streaming. +/// +/// The lines are already rendered for the stream's current width. A finalized transcript should not +/// keep these cells after consolidation, because they cannot re-render their source on a later +/// terminal resize. #[derive(Debug)] pub(crate) struct ProposedPlanStreamCell { lines: Vec>, @@ -2911,6 +2980,7 @@ mod tests { use crate::exec_cell::ExecCell; use crate::legacy_core::config::Config; use crate::legacy_core::config::ConfigBuilder; + use crate::wrapping::word_wrap_lines; use codex_config::types::McpServerConfig; use codex_config::types::McpServerDisabledReason; use codex_otel::RuntimeMetricTotals; @@ -2925,6 +2995,8 @@ mod tests { use codex_protocol::protocol::SessionConfiguredEvent; use dirs::home_dir; use pretty_assertions::assert_eq; + use ratatui::buffer::Buffer; + use ratatui::layout::Rect; use serde_json::json; use std::collections::HashMap; use std::path::PathBuf; @@ -4918,4 +4990,211 @@ mod tests { ] ); } + + #[test] + fn agent_markdown_cell_renders_source_at_different_widths() { + let source = + "A long agent message that should wrap differently when the terminal width changes.\n"; + let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd()); + + let lines_80 = render_lines(&cell.display_lines(/*width*/ 80)); + assert!( + lines_80.first().is_some_and(|line| line.starts_with("• ")), + "first line should start with bullet prefix: {:?}", + lines_80[0] + ); + + let lines_32 = render_lines(&cell.display_lines(/*width*/ 32)); + assert!( + lines_32.len() > lines_80.len(), + "narrower width should produce more wrapped lines: {lines_32:?}", + ); + } + + #[test] + fn agent_markdown_cell_narrow_width_shows_prefix_only() { + let source = "narrow width coverage\n"; + let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd()); + + let lines = render_lines(&cell.display_lines(/*width*/ 2)); + assert_eq!(lines, vec!["• ".to_string()]); + } + + #[test] + fn wrapped_and_prefixed_cells_handle_tiny_widths() { + let user_cell = UserHistoryCell { + message: "tiny width coverage for wrapped user history".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }; + let agent_message_cell = AgentMessageCell::new( + vec!["tiny width agent line".into()], + /*is_first_line*/ true, + ); + let reasoning_cell = ReasoningSummaryCell::new( + "Plan".to_string(), + "Reasoning summary content for tiny widths.".to_string(), + &test_cwd(), + /*transcript_only*/ false, + ); + let agent_markdown_cell = + AgentMarkdownCell::new("tiny width agent markdown line\n".to_string(), &test_cwd()); + + for width in 1..=4 { + assert!( + !user_cell.display_lines(width).is_empty(), + "user cell should render at width {width}", + ); + assert!( + !agent_message_cell.display_lines(width).is_empty(), + "agent message cell should render at width {width}", + ); + assert!( + !reasoning_cell.display_lines(width).is_empty(), + "reasoning cell should render at width {width}", + ); + assert!( + !agent_markdown_cell.display_lines(width).is_empty(), + "agent markdown cell should render at width {width}", + ); + } + } + + #[test] + fn render_clears_area_when_cell_content_shrinks() { + let area = Rect::new(0, 0, 40, 6); + let mut buf = Buffer::empty(area); + + let first: Box = Box::new(PlainHistoryCell::new(vec![ + Line::from("STALE ROW 1"), + Line::from("STALE ROW 2"), + Line::from("STALE ROW 3"), + Line::from("STALE ROW 4"), + ])); + first.render(area, &mut buf); + + let second: Box = + Box::new(PlainHistoryCell::new(vec![Line::from("fresh")])); + second.render(area, &mut buf); + + let mut rendered_rows: Vec = Vec::new(); + for y in 0..area.height { + let mut row = String::new(); + for x in 0..area.width { + row.push_str(buf.cell((x, y)).expect("cell should exist").symbol()); + } + rendered_rows.push(row); + } + + assert!( + rendered_rows.iter().all(|row| !row.contains("STALE")), + "rendered buffer should not retain stale glyphs: {rendered_rows:?}", + ); + assert!( + rendered_rows + .first() + .is_some_and(|row| row.contains("fresh")), + "expected fresh content in first row: {rendered_rows:?}", + ); + } + + #[test] + fn agent_markdown_cell_survives_insert_history_rewrap() { + let source = "\ + Canary rollout remained at limited traffic longer than planned because p95 + latency briefly regressed during cold-cache periods. + Regional expansion succeeded with stable error rates, though internal + analytics lagged temporarily. + "; + let cell = AgentMarkdownCell::new(source.to_string(), &test_cwd()); + let width: u16 = 80; + let lines = cell.display_lines(width); + + // Simulate what insert_history_lines does: word_wrap_lines with + // the terminal width and no indent. + let rewrapped = word_wrap_lines(&lines, width as usize); + let before = render_lines(&lines); + let after = render_lines(&rewrapped); + assert_eq!( + before, after, + "word_wrap_lines should not alter lines that already fit within width" + ); + } + + /// Simulate the consolidation backward-walk logic from `App::handle_event` + /// to verify it correctly identifies and replaces `AgentMessageCell` runs. + #[test] + fn consolidation_walker_replaces_agent_message_cells() { + use std::sync::Arc; + + // Build a transcript with: [UserCell, AgentMsg(head), AgentMsg(cont), AgentMsg(cont)] + let user = Arc::new(UserHistoryCell { + message: "hello".to_string(), + text_elements: Vec::new(), + local_image_paths: Vec::new(), + remote_image_urls: Vec::new(), + }) as Arc; + let head = Arc::new(AgentMessageCell::new( + vec![Line::from("line 1")], + /*is_first_line*/ true, + )) as Arc; + let cont1 = Arc::new(AgentMessageCell::new( + vec![Line::from("line 2")], + /*is_first_line*/ false, + )) as Arc; + let cont2 = Arc::new(AgentMessageCell::new( + vec![Line::from("line 3")], + /*is_first_line*/ false, + )) as Arc; + + let mut transcript_cells: Vec> = + vec![user.clone(), head, cont1, cont2]; + + // Run the same consolidation logic as the handler. + let source = "line 1\nline 2\nline 3\n".to_string(); + let end = transcript_cells.len(); + let mut start = end; + while start > 0 + && transcript_cells[start - 1].is_stream_continuation() + && transcript_cells[start - 1] + .as_any() + .is::() + { + start -= 1; + } + if start > 0 + && transcript_cells[start - 1] + .as_any() + .is::() + && !transcript_cells[start - 1].is_stream_continuation() + { + start -= 1; + } + + assert_eq!( + start, 1, + "should find all 3 agent cells starting at index 1" + ); + assert_eq!(end, 4); + + // Splice. + let consolidated: Arc = + Arc::new(AgentMarkdownCell::new(source, &test_cwd())); + transcript_cells.splice(start..end, std::iter::once(consolidated)); + + assert_eq!(transcript_cells.len(), 2, "should be [user, consolidated]"); + + // Verify first cell is still the user cell. + assert!( + transcript_cells[0].as_any().is::(), + "first cell should be UserHistoryCell" + ); + + // Verify second cell is AgentMarkdownCell. + assert!( + transcript_cells[1].as_any().is::(), + "second cell should be AgentMarkdownCell" + ); + } } diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index 76cd699e8..4f3ea981b 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -1,3 +1,9 @@ +//! Inserts finalized history rows into terminal scrollback. +//! +//! Codex uses the terminal scrollback itself for finalized chat history, so inserting a history +//! cell is an escape-sequence operation rather than a normal ratatui render. The mode determines +//! how to create room for new history above the inline viewport. + use std::fmt; use std::io; use std::io::Write; @@ -70,7 +76,8 @@ where /// 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. +/// viewport moved to. Resize reflow uses the same viewport-aware path after +/// clearing old scrollback. pub fn insert_history_lines_with_mode( terminal: &mut crate::custom_terminal::Terminal, lines: Vec, @@ -116,81 +123,87 @@ where } let wrapped_lines = wrapped_rows as u16; - 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); + match 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); - 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"))?; + if scroll_up_amount > 0 { + // Scroll the entire screen up by emitting \n at the bottom + queue!( + writer, + MoveTo(/*x*/ 0, screen_size.height.saturating_sub(1)) + )?; + for _ in 0..scroll_up_amount { + queue!(writer, Print("\n"))?; + } + } + + 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(/*x*/ 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)?; } } + InsertHistoryMode::Standard => { + let cursor_top = if area.bottom() < screen_size.height { + let scroll_amount = wrapped_lines.min(screen_size.height - area.bottom()); - if shift_down > 0 { - area.y += shift_down; - should_update_area = true; - } + let top_1based = area.top() + 1; + queue!(writer, SetScrollRegion(top_1based..screen_size.height))?; + queue!(writer, MoveTo(/*x*/ 0, area.top()))?; + for _ in 0..scroll_amount { + queue!(writer, Print("\x1bM"))?; + } + queue!(writer, ResetScrollRegion)?; - let cursor_top = area.top().saturating_sub(scroll_up_amount + shift_down); - queue!(writer, MoveTo(0, cursor_top))?; + let cursor_top = area.top().saturating_sub(1); + area.y += scroll_amount; + should_update_area = true; + cursor_top + } else { + area.top().saturating_sub(1) + }; - for (i, line) in wrapped.iter().enumerate() { - if i > 0 { + // 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(/*x*/ 0, cursor_top))?; + + for line in &wrapped { queue!(writer, Print("\r\n"))?; + write_history_line(writer, line, wrap_width)?; } - 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. @@ -806,14 +819,20 @@ mod tests { 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); + let viewport = Rect::new(/*x*/ 0, /*y*/ 4, width, /*height*/ 2); term.set_viewport_area(viewport); let line: Line<'static> = Line::from("zellij history"); insert_history_lines_with_mode(&mut term, vec![line], InsertHistoryMode::Zellij) .expect("insert zellij history"); - let rows: Vec = term.backend().vt100().screen().rows(0, width).collect(); + let start_row = 0; + let rows: Vec = term + .backend() + .vt100() + .screen() + .rows(start_row, width) + .collect(); assert!( rows.iter().any(|row| row.contains("zellij history")), "expected zellij history row in screen output, rows: {rows:?}" diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index a36177fda..7f65e3b04 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -149,6 +149,7 @@ mod oss_selection; mod pager_overlay; pub(crate) mod public_widgets; mod render; +mod resize_reflow_cap; mod resume_picker; mod selection_list; mod session_log; @@ -164,6 +165,7 @@ mod terminal_title; mod text_formatting; mod theme_picker; mod tooltips; +mod transcript_reflow; mod tui; mod ui_consts; pub(crate) mod update_action; @@ -175,6 +177,7 @@ mod updates; mod version; #[cfg(not(target_os = "linux"))] mod voice; +mod width; #[cfg(target_os = "linux")] #[allow(dead_code)] mod voice { diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 3eb37e345..311ea202c 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -1,55 +1,136 @@ +//! Collects markdown stream source at newline boundaries. +//! +//! `MarkdownStreamCollector` buffers incoming token deltas and exposes a commit boundary at each +//! newline. The stream controllers (`streaming/controller.rs`) call `commit_complete_source()` +//! after each newline-bearing delta to obtain the completed prefix for re-rendering, leaving the +//! trailing incomplete line in the buffer for the next delta. +//! +//! On finalization, `finalize_and_drain_source()` flushes whatever remains (the last line, which +//! may lack a trailing newline). + +#[cfg(test)] use ratatui::text::Line; use std::path::Path; +#[cfg(test)] use std::path::PathBuf; +#[cfg(test)] use crate::markdown; -/// Newline-gated accumulator that renders markdown and commits only fully -/// completed logical lines. +/// Newline-gated accumulator that buffers raw markdown source and commits only completed lines. +/// +/// The buffer tracks how many source bytes have already been committed via +/// `committed_source_len`, so each `commit_complete_source()` call returns only the newly +/// completed portion. This design lets the stream controller re-render the entire accumulated +/// source while only appending new content. +/// +/// The collector does not parse markdown in production. It only defines stable source boundaries; +/// rendering lives in the stream controllers so width changes can re-render from one accumulated +/// source string. pub(crate) struct MarkdownStreamCollector { buffer: String, + committed_source_len: usize, + #[cfg(test)] committed_line_count: usize, width: Option, + #[cfg(test)] cwd: PathBuf, } impl MarkdownStreamCollector { - /// Create a collector that renders markdown using `cwd` for local file-link display. + /// Create a collector that accumulates raw markdown deltas. /// - /// The collector snapshots `cwd` into owned state because stream commits can happen long after - /// construction. The same `cwd` should be reused for the entire stream lifecycle; mixing - /// different working directories within one stream would make the same link render with - /// different path prefixes across incremental commits. + /// `width` and `cwd` are only used by test-only rendering helpers; production stream commits + /// operate on raw source boundaries. The collector snapshots `cwd` so test rendering keeps + /// local file-link display stable across incremental commits. pub fn new(width: Option, cwd: &Path) -> Self { + #[cfg(not(test))] + let _ = cwd; + Self { buffer: String::new(), + committed_source_len: 0, + #[cfg(test)] committed_line_count: 0, width, + #[cfg(test)] cwd: cwd.to_path_buf(), } } - pub fn clear(&mut self) { - self.buffer.clear(); - self.committed_line_count = 0; + /// Update the rendering width used by test-only line-commit helpers. + pub fn set_width(&mut self, width: Option) { + self.width = width; } + /// Reset all buffered source and commit bookkeeping. + pub fn clear(&mut self) { + self.buffer.clear(); + self.committed_source_len = 0; + #[cfg(test)] + { + self.committed_line_count = 0; + } + } + + /// Append a raw streaming delta to the internal source buffer. pub fn push_delta(&mut self, delta: &str) { tracing::trace!("push_delta: {delta:?}"); self.buffer.push_str(delta); } + /// Commit newly completed raw markdown source up to the last newline. + /// + /// This returns only source that has not been returned by a previous commit. Calling it after a + /// delta without a newline returns `None`, which prevents the live stream from rendering + /// incomplete markdown blocks that may change meaning when the rest of the line arrives. + pub fn commit_complete_source(&mut self) -> Option { + let commit_end = self.buffer.rfind('\n').map(|idx| idx + 1)?; + if commit_end <= self.committed_source_len { + return None; + } + + let out = self.buffer[self.committed_source_len..commit_end].to_string(); + self.committed_source_len = commit_end; + Some(out) + } + + /// Finalize the stream and return any remaining raw source. + /// + /// Ensures the returned source chunk is newline-terminated when non-empty so callers can + /// safely run markdown block parsing on the final chunk. This method clears the collector; + /// callers should not invoke it until the stream is truly complete or interrupted output is + /// being intentionally consolidated. + pub fn finalize_and_drain_source(&mut self) -> String { + if self.committed_source_len >= self.buffer.len() { + self.clear(); + return String::new(); + } + + let mut out = self.buffer[self.committed_source_len..].to_string(); + if !out.ends_with('\n') { + out.push('\n'); + } + self.clear(); + out + } + /// Render the full buffer and return only the newly completed logical lines /// since the last commit. When the buffer does not end with a newline, the /// final rendered line is considered incomplete and is not emitted. + /// + /// This helper intentionally uses `append_markdown` (not + /// `append_markdown_agent`) so tests can isolate collector newline boundary + /// behavior without stream-controller holdback semantics. + #[cfg(test)] pub fn commit_complete_lines(&mut self) -> Vec> { - let source = self.buffer.clone(); - let last_newline_idx = source.rfind('\n'); - let source = if let Some(last_newline_idx) = last_newline_idx { - source[..=last_newline_idx].to_string() - } else { + let Some(commit_end) = self.buffer.rfind('\n').map(|idx| idx + 1) else { return Vec::new(); }; + if commit_end <= self.committed_source_len { + return Vec::new(); + } + let source = self.buffer[..commit_end].to_string(); let mut rendered: Vec> = Vec::new(); markdown::append_markdown(&source, self.width, Some(self.cwd.as_path()), &mut rendered); let mut complete_line_count = rendered.len(); @@ -68,25 +149,29 @@ impl MarkdownStreamCollector { let out_slice = &rendered[self.committed_line_count..complete_line_count]; let out = out_slice.to_vec(); + self.committed_source_len = commit_end; self.committed_line_count = complete_line_count; out } /// Finalize the stream: emit all remaining lines beyond the last commit. /// If the buffer does not end with a newline, a temporary one is appended - /// for rendering. Optionally unwraps ```markdown language fences in - /// non-test builds. + /// for rendering. + #[cfg(test)] pub fn finalize_and_drain(&mut self) -> Vec> { - let raw_buffer = self.buffer.clone(); - let mut source: String = raw_buffer.clone(); + let mut source = self.buffer.clone(); + if source.is_empty() { + self.clear(); + return Vec::new(); + } if !source.ends_with('\n') { source.push('\n'); - } + }; tracing::debug!( - raw_len = raw_buffer.len(), + raw_len = self.buffer.len(), source_len = source.len(), "markdown finalize (raw length: {}, rendered length: {})", - raw_buffer.len(), + self.buffer.len(), source.len() ); tracing::trace!("markdown finalize (raw source):\n---\n{source}\n---"); @@ -416,6 +501,42 @@ mod tests { .collect() } + #[tokio::test] + async fn table_header_commits_without_holdback() { + let mut c = super::MarkdownStreamCollector::new(/*width*/ None, &super::test_cwd()); + c.push_delta("| A | B |\n"); + let out1 = c.commit_complete_lines(); + let out1_str = lines_to_plain_strings(&out1); + assert_eq!(out1_str, vec!["| A | B |".to_string()]); + + c.push_delta("| --- | --- |\n"); + let out = c.commit_complete_lines(); + let out_str = lines_to_plain_strings(&out); + assert!( + !out_str.is_empty(), + "expected output to continue committing after delimiter: {out_str:?}" + ); + + c.push_delta("| 1 | 2 |\n"); + let out2 = c.commit_complete_lines(); + assert!( + !out2.is_empty(), + "expected output to continue committing after body row" + ); + + c.push_delta("\n"); + let _ = c.commit_complete_lines(); + } + + #[tokio::test] + async fn pipe_text_without_table_prefix_is_not_delayed() { + let mut c = super::MarkdownStreamCollector::new(/*width*/ None, &super::test_cwd()); + c.push_delta("Escaped pipe in text: a | b | c\n"); + let out = c.commit_complete_lines(); + let out_str = lines_to_plain_strings(&out); + assert_eq!(out_str, vec!["Escaped pipe in text: a | b | c".to_string()]); + } + #[tokio::test] async fn lists_and_fences_commit_without_duplication() { // List case @@ -722,4 +843,9 @@ mod tests { ]) .await; } + + #[tokio::test] + async fn table_like_lines_inside_fenced_code_are_not_held() { + assert_streamed_equals_full(&["```\n", "| a | b |\n", "```\n"]).await; + } } diff --git a/codex-rs/tui/src/model_migration.rs b/codex-rs/tui/src/model_migration.rs index 1b2de5ecf..c307abb78 100644 --- a/codex-rs/tui/src/model_migration.rs +++ b/codex-rs/tui/src/model_migration.rs @@ -153,7 +153,7 @@ pub(crate) async fn run_model_migration_prompt( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { let _ = alt.tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); }); diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 0c7ebda08..0a6e8a3d7 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -480,7 +480,7 @@ pub(crate) async fn run_onboarding_app( TuiEvent::Paste(text) => { onboarding_screen.handle_paste(text); } - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { if !did_full_clear_after_success && onboarding_screen.steps.iter().any(|step| { if let Step::Auth(w) = step { diff --git a/codex-rs/tui/src/pager_overlay.rs b/codex-rs/tui/src/pager_overlay.rs index bca5f1f36..9fe0e3916 100644 --- a/codex-rs/tui/src/pager_overlay.rs +++ b/codex-rs/tui/src/pager_overlay.rs @@ -566,6 +566,49 @@ impl TranscriptOverlay { } } + /// Replace a range of committed cells with a single consolidated cell. + /// + /// Mirrors the splice performed on `App::transcript_cells` during + /// `ConsolidateAgentMessage` so the Ctrl+T overlay stays in sync with the + /// main transcript. The range is clamped defensively: cells may have been + /// inserted after the overlay opened, leaving it with fewer entries than + /// the main transcript. + pub(crate) fn consolidate_cells( + &mut self, + range: std::ops::Range, + consolidated: Arc, + ) { + let follow_bottom = self.view.is_scrolled_to_bottom(); + // Clamp the range to the overlay's cell count to avoid panic if the overlay has fewer + // cells than the main transcript (e.g. cells were inserted after the overlay has opened). + let clamped_end = range.end.min(self.cells.len()); + let clamped_start = range.start.min(clamped_end); + if clamped_start < clamped_end { + let removed = clamped_end - clamped_start; + if let Some(highlight_cell) = self.highlight_cell.as_mut() + && *highlight_cell >= clamped_start + { + if *highlight_cell < clamped_end { + *highlight_cell = clamped_start; + } else { + *highlight_cell = highlight_cell.saturating_sub(removed.saturating_sub(1)); + } + } + self.cells + .splice(clamped_start..clamped_end, std::iter::once(consolidated)); + if self + .highlight_cell + .is_some_and(|highlight_cell| highlight_cell >= self.cells.len()) + { + self.highlight_cell = None; + } + self.rebuild_renderables(); + } + if follow_bottom { + self.view.scroll_offset = usize::MAX; + } + } + /// Sync the active-cell live tail with the current width and cell state. /// /// Recomputes the tail only when the cache key changes, preserving scroll @@ -700,7 +743,7 @@ impl TranscriptOverlay { } other => self.view.handle_key_event(tui, other), }, - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; @@ -764,7 +807,7 @@ impl StaticOverlay { } other => self.view.handle_key_event(tui, other), }, - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { self.render(frame.area(), frame.buffer); })?; @@ -1090,6 +1133,60 @@ mod tests { assert_eq!(overlay.view.scroll_offset, 0); } + #[test] + fn transcript_overlay_consolidation_remaps_highlight_inside_range() { + let mut overlay = TranscriptOverlay::new( + (0..6) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); + overlay.set_highlight_cell(Some(3)); + + overlay.consolidate_cells( + 2..5, + Arc::new(TestCell { + lines: vec![Line::from("consolidated")], + }), + ); + + assert_eq!( + overlay.highlight_cell, + Some(2), + "highlight inside consolidated range should point to replacement cell", + ); + } + + #[test] + fn transcript_overlay_consolidation_remaps_highlight_after_range() { + let mut overlay = TranscriptOverlay::new( + (0..7) + .map(|i| { + Arc::new(TestCell { + lines: vec![Line::from(format!("line{i}"))], + }) as Arc + }) + .collect(), + ); + overlay.set_highlight_cell(Some(6)); + + overlay.consolidate_cells( + 2..5, + Arc::new(TestCell { + lines: vec![Line::from("consolidated")], + }), + ); + + assert_eq!( + overlay.highlight_cell, + Some(4), + "highlight after consolidated range should shift left by removed cells", + ); + } + #[test] fn static_overlay_snapshot_basic() { // Prepare a static overlay with a few lines and a title diff --git a/codex-rs/tui/src/render/line_utils.rs b/codex-rs/tui/src/render/line_utils.rs index 175b79b2a..54970f448 100644 --- a/codex-rs/tui/src/render/line_utils.rs +++ b/codex-rs/tui/src/render/line_utils.rs @@ -26,6 +26,7 @@ pub fn push_owned_lines<'a>(src: &[Line<'a>], out: &mut Vec>) { /// Consider a line blank if it has no spans or only spans whose contents are /// empty or consist solely of spaces (no tabs/newlines). +#[cfg(test)] pub fn is_blank_line_spaces_only(line: &Line<'_>) -> bool { if line.spans.is_empty() { return true; diff --git a/codex-rs/tui/src/resize_reflow_cap.rs b/codex-rs/tui/src/resize_reflow_cap.rs new file mode 100644 index 000000000..4dd9ffb19 --- /dev/null +++ b/codex-rs/tui/src/resize_reflow_cap.rs @@ -0,0 +1,183 @@ +//! Terminal-specific row caps for resize reflow. +//! +//! The auto cap mirrors documented scrollback defaults for terminals we can identify. Console Host +//! does not expose its configured screen buffer through terminal metadata, so it usually lands in +//! the fallback bucket. +//! +//! These caps are deliberately conservative: Codex is rebuilding normal terminal scrollback, not an +//! internal virtual transcript. Replaying more rows than the terminal retains wastes work and can +//! make interactive resize feel worse without giving the user more usable history. + +use codex_config::types::DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; + +use crate::legacy_core::config::TerminalResizeReflowConfig; +use crate::legacy_core::config::TerminalResizeReflowMaxRows; + +const VSCODE_RESIZE_REFLOW_MAX_ROWS: usize = 1_000; +const WINDOWS_TERMINAL_RESIZE_REFLOW_MAX_ROWS: usize = 9_001; +const WEZTERM_RESIZE_REFLOW_MAX_ROWS: usize = 3_500; +const ALACRITTY_RESIZE_REFLOW_MAX_ROWS: usize = 10_000; + +/// Resolve the configured row cap for resize and initial replay. +/// +/// `Auto` uses terminal detection plus the VS Code environment probe because VS Code can run shells +/// whose terminal-name metadata points at the host shell rather than VS Code itself. Returning +/// `None` means the user explicitly disabled row limiting with `max_rows = 0`. +pub(crate) fn resize_reflow_max_rows(config: TerminalResizeReflowConfig) -> Option { + resize_reflow_max_rows_for( + config, + &terminal_info(), + crate::tui::running_in_vscode_terminal(), + ) +} + +fn resize_reflow_max_rows_for( + config: TerminalResizeReflowConfig, + terminal: &TerminalInfo, + running_in_vscode_terminal: bool, +) -> Option { + match config.max_rows { + TerminalResizeReflowMaxRows::Auto => Some(auto_resize_reflow_max_rows( + terminal.name, + running_in_vscode_terminal, + )), + TerminalResizeReflowMaxRows::Disabled => None, + TerminalResizeReflowMaxRows::Limit(max_rows) => Some(max_rows), + } +} + +fn auto_resize_reflow_max_rows( + terminal_name: TerminalName, + running_in_vscode_terminal: bool, +) -> usize { + if running_in_vscode_terminal { + return VSCODE_RESIZE_REFLOW_MAX_ROWS; + } + + match terminal_name { + TerminalName::VsCode => VSCODE_RESIZE_REFLOW_MAX_ROWS, + TerminalName::WindowsTerminal => WINDOWS_TERMINAL_RESIZE_REFLOW_MAX_ROWS, + TerminalName::WezTerm => WEZTERM_RESIZE_REFLOW_MAX_ROWS, + TerminalName::Alacritty => ALACRITTY_RESIZE_REFLOW_MAX_ROWS, + TerminalName::AppleTerminal + | TerminalName::Ghostty + | TerminalName::Iterm2 + | TerminalName::WarpTerminal + | TerminalName::Kitty + | TerminalName::Konsole + | TerminalName::GnomeTerminal + | TerminalName::Vte + | TerminalName::Dumb + | TerminalName::Unknown => DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use codex_terminal_detection::Multiplexer; + + fn test_terminal(name: TerminalName) -> TerminalInfo { + TerminalInfo { + name, + term_program: None, + version: None, + term: None, + multiplexer: None, + } + } + + #[test] + fn auto_resize_reflow_max_rows_uses_terminal_defaults() { + let cases = [ + (TerminalName::VsCode, VSCODE_RESIZE_REFLOW_MAX_ROWS), + ( + TerminalName::WindowsTerminal, + WINDOWS_TERMINAL_RESIZE_REFLOW_MAX_ROWS, + ), + (TerminalName::WezTerm, WEZTERM_RESIZE_REFLOW_MAX_ROWS), + (TerminalName::Alacritty, ALACRITTY_RESIZE_REFLOW_MAX_ROWS), + ( + TerminalName::Ghostty, + DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS, + ), + ( + TerminalName::Unknown, + DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS, + ), + ]; + + for (terminal_name, expected_max_rows) in cases { + assert_eq!( + auto_resize_reflow_max_rows( + terminal_name, + /*running_in_vscode_terminal*/ false + ), + expected_max_rows + ); + } + } + + #[test] + fn auto_resize_reflow_max_rows_prefers_vscode_probe() { + assert_eq!( + auto_resize_reflow_max_rows( + TerminalName::WindowsTerminal, + /*running_in_vscode_terminal*/ true + ), + VSCODE_RESIZE_REFLOW_MAX_ROWS + ); + } + + #[test] + fn configured_resize_reflow_max_rows_overrides_auto_detection() { + let terminal = test_terminal(TerminalName::VsCode); + let config = TerminalResizeReflowConfig { + max_rows: TerminalResizeReflowMaxRows::Limit(42), + }; + + assert_eq!( + resize_reflow_max_rows_for( + config, &terminal, /*running_in_vscode_terminal*/ false + ), + Some(42) + ); + } + + #[test] + fn disabled_resize_reflow_max_rows_keeps_all_rows() { + let terminal = test_terminal(TerminalName::VsCode); + let config = TerminalResizeReflowConfig { + max_rows: TerminalResizeReflowMaxRows::Disabled, + }; + + assert_eq!( + resize_reflow_max_rows_for( + config, &terminal, /*running_in_vscode_terminal*/ false + ), + None + ); + } + + #[test] + fn unknown_terminal_uses_fallback_even_under_multiplexer() { + let terminal = TerminalInfo { + name: TerminalName::Unknown, + term_program: None, + version: None, + term: Some("xterm-256color".to_string()), + multiplexer: Some(Multiplexer::Tmux { version: None }), + }; + let config = TerminalResizeReflowConfig::default(); + + assert_eq!( + resize_reflow_max_rows_for( + config, &terminal, /*running_in_vscode_terminal*/ false + ), + Some(DEFAULT_TERMINAL_RESIZE_REFLOW_FALLBACK_MAX_ROWS) + ); + } +} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index fe0825ccd..43fa6c948 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -248,7 +248,7 @@ async fn run_session_picker_with_loader( return Ok(sel); } } - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { if let Ok(size) = alt.tui.terminal.size() { let list_height = size.height.saturating_sub(4) as usize; state.update_view_rows(list_height); diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index d524e707a..2def4ae8b 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -1,103 +1,292 @@ +//! Streams markdown deltas while retaining source for later transcript reflow. +//! +//! Streaming has two outputs with different lifetimes. The live viewport needs incremental +//! `HistoryCell`s so the user sees progress, while finalized transcript history needs raw markdown +//! source so it can be rendered again after a terminal resize. These controllers keep those outputs +//! tied together: newline-complete source is rendered into queued live cells, and finalization +//! returns the accumulated source to the app for consolidation. +//! +//! Width changes are handled by re-rendering from source and rebuilding only the not-yet-emitted +//! queue. Already emitted rows stay emitted until the app-level transcript reflow rebuilds the full +//! scrollback from finalized cells. + use crate::history_cell::HistoryCell; use crate::history_cell::{self}; +use crate::markdown::append_markdown; use crate::render::line_utils::prefix_lines; use crate::style::proposed_plan_style; use ratatui::prelude::Stylize; use ratatui::text::Line; use std::path::Path; +use std::path::PathBuf; use std::time::Duration; use std::time::Instant; use super::StreamState; -/// Controller that manages newline-gated streaming, header emission, and -/// commit animation across streams. -pub(crate) struct StreamController { +/// Shared source-retaining stream state for assistant and plan output. +/// +/// `raw_source` is the markdown source that has crossed a newline boundary and can be rendered +/// deterministically. `rendered_lines` is the current-width render of that source. `enqueued_len` +/// tracks how much of that render has been offered to the commit queue, while `emitted_len` tracks +/// how much has actually reached history cells. Keeping those counters separate lets width changes +/// rebuild pending output without duplicating lines that are already visible. +struct StreamCore { state: StreamState, - finishing_after_drain: bool, + width: Option, + raw_source: String, + rendered_lines: Vec>, + enqueued_len: usize, + emitted_len: usize, + cwd: PathBuf, +} + +impl StreamCore { + fn new(width: Option, cwd: &Path) -> Self { + Self { + state: StreamState::new(width, cwd), + width, + raw_source: String::with_capacity(1024), + rendered_lines: Vec::with_capacity(64), + enqueued_len: 0, + emitted_len: 0, + cwd: cwd.to_path_buf(), + } + } + + fn push_delta(&mut self, delta: &str) -> bool { + if !delta.is_empty() { + self.state.has_seen_delta = true; + } + self.state.collector.push_delta(delta); + + if delta.contains('\n') + && let Some(committed_source) = self.state.collector.commit_complete_source() + { + self.raw_source.push_str(&committed_source); + self.recompute_render(); + return self.sync_queue_to_render(); + } + + false + } + + fn finalize_remaining(&mut self) -> Vec> { + let remainder_source = self.state.collector.finalize_and_drain_source(); + if !remainder_source.is_empty() { + self.raw_source.push_str(&remainder_source); + } + + let mut rendered = Vec::new(); + append_markdown( + &self.raw_source, + self.width, + Some(self.cwd.as_path()), + &mut rendered, + ); + if self.emitted_len >= rendered.len() { + Vec::new() + } else { + rendered[self.emitted_len..].to_vec() + } + } + + fn tick(&mut self) -> Vec> { + let step = self.state.step(); + self.emitted_len += step.len(); + step + } + + fn tick_batch(&mut self, max_lines: usize) -> Vec> { + if max_lines == 0 { + return Vec::new(); + } + let step = self.state.drain_n(max_lines); + self.emitted_len += step.len(); + step + } + + fn queued_lines(&self) -> usize { + self.state.queued_len() + } + + fn oldest_queued_age(&self, now: Instant) -> Option { + self.state.oldest_queued_age(now) + } + + fn is_idle(&self) -> bool { + self.state.is_idle() + } + + fn set_width(&mut self, width: Option) { + if self.width == width { + return; + } + + let had_pending_queue = self.state.queued_len() > 0; + self.width = width; + self.state.collector.set_width(width); + if self.raw_source.is_empty() { + return; + } + + self.recompute_render(); + self.emitted_len = self.emitted_len.min(self.rendered_lines.len()); + if had_pending_queue + && self.emitted_len == self.rendered_lines.len() + && self.emitted_len > 0 + { + // If wrapped remainder compresses into fewer lines at the new width, + // keep at least one line un-emitted so pre-resize pending content is + // not skipped permanently. + self.emitted_len -= 1; + } + + self.state.clear_queue(); + if self.emitted_len > 0 && !had_pending_queue { + self.enqueued_len = self.rendered_lines.len(); + return; + } + self.rebuild_queue_from_render(); + } + + fn clear_queue(&mut self) { + self.state.clear_queue(); + self.enqueued_len = self.emitted_len; + } + + fn reset(&mut self) { + self.state.clear(); + self.raw_source.clear(); + self.rendered_lines.clear(); + self.enqueued_len = 0; + self.emitted_len = 0; + } + + fn recompute_render(&mut self) { + self.rendered_lines.clear(); + append_markdown( + &self.raw_source, + self.width, + Some(self.cwd.as_path()), + &mut self.rendered_lines, + ); + } + + /// Append newly rendered lines to the live queue without replaying already queued rows. + /// + /// Width changes can make the rendered line count smaller than the previous queue boundary; in + /// that case the only safe option is rebuilding the queue from `emitted_len`, because slicing + /// from the stale `enqueued_len` would skip pending source. + fn sync_queue_to_render(&mut self) -> bool { + let target_len = self.rendered_lines.len().max(self.emitted_len); + if target_len < self.enqueued_len { + self.rebuild_queue_from_render(); + return self.state.queued_len() > 0; + } + + if target_len == self.enqueued_len { + return false; + } + + self.state + .enqueue(self.rendered_lines[self.enqueued_len..target_len].to_vec()); + self.enqueued_len = target_len; + true + } + + /// Rebuild the pending live queue from the current render and current emitted position. + /// + /// This is used when resize invalidates queued wrapping. It must never enqueue rows before + /// `emitted_len`, because those rows have already been inserted into terminal history. + fn rebuild_queue_from_render(&mut self) { + self.state.clear_queue(); + let target_len = self.rendered_lines.len().max(self.emitted_len); + if self.emitted_len < target_len { + self.state + .enqueue(self.rendered_lines[self.emitted_len..target_len].to_vec()); + } + self.enqueued_len = target_len; + } +} + +/// Controls newline-gated streaming for assistant messages. +/// +/// The controller emits transient `AgentMessageCell`s for live display and returns raw markdown +/// source on `finalize` so the app can replace those transient cells with a source-backed +/// `AgentMarkdownCell`. Callers should use `set_width` on terminal resize; rebuilding the queue +/// from already emitted cells would duplicate output instead of preserving the stream position. +pub(crate) struct StreamController { + core: StreamCore, header_emitted: bool, } impl StreamController { - /// Create a controller whose markdown renderer shortens local file links relative to `cwd`. + /// Create a stream controller that renders markdown relative to the given width and cwd. /// - /// The controller snapshots the path into stream state so later commit ticks and finalization - /// render against the same session cwd that was active when streaming started. + /// `width` is the content width available to markdown rendering, not necessarily the full + /// terminal width. Passing a stale width after resize will keep queued live output wrapped for + /// the old viewport until app-level reflow repairs the finalized transcript. pub(crate) fn new(width: Option, cwd: &Path) -> Self { Self { - state: StreamState::new(width, cwd), - finishing_after_drain: false, + core: StreamCore::new(width, cwd), header_emitted: false, } } - /// Push a delta; if it contains a newline, commit completed lines and start animation. - pub(crate) fn push(&mut self, delta: &str) -> bool { - let state = &mut self.state; - if !delta.is_empty() { - state.has_seen_delta = true; - } - state.collector.push_delta(delta); - if delta.contains('\n') { - let newly_completed = state.collector.commit_complete_lines(); - if !newly_completed.is_empty() { - state.enqueue(newly_completed); - return true; - } - } - false - } - - /// Finalize the active stream. Drain and emit now. - pub(crate) fn finalize(&mut self) -> Option> { - // Finalize collector first. - let remaining = { - let state = &mut self.state; - state.collector.finalize_and_drain() - }; - // Collect all output first to avoid emitting headers when there is no content. - let mut out_lines = Vec::new(); - { - let state = &mut self.state; - if !remaining.is_empty() { - state.enqueue(remaining); - } - let step = state.drain_all(); - out_lines.extend(step); - } - - // Cleanup - self.state.clear(); - self.finishing_after_drain = false; - self.emit(out_lines) - } - - /// Step animation: commit at most one queued line and handle end-of-drain cleanup. - pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { - let step = self.state.step(); - (self.emit(step), self.state.is_idle()) - } - - /// Step animation: commit at most `max_lines` queued lines. + /// Push a raw model delta and return whether it produced queued complete lines. /// - /// This is intended for adaptive catch-up drains. Callers should keep `max_lines` bounded; a - /// very large value can collapse perceived animation into a single jump. + /// Deltas are committed only through newline boundaries. A `false` return can still mean source + /// was buffered; it only means no newly renderable complete line is ready for live emission. + pub(crate) fn push(&mut self, delta: &str) -> bool { + self.core.push_delta(delta) + } + + /// Finish the stream and return the final transient cell plus accumulated markdown source. + /// + /// The source is `None` only when the stream never accumulated content. Callers that discard the + /// returned source cannot later consolidate the transcript into a width-sensitive finalized + /// cell. + pub(crate) fn finalize(&mut self) -> (Option>, Option) { + let remaining = self.core.finalize_remaining(); + if self.core.raw_source.is_empty() { + self.core.reset(); + return (None, None); + } + + let source = std::mem::take(&mut self.core.raw_source); + let out = self.emit(remaining); + self.core.reset(); + (out, Some(source)) + } + + pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { + let step = self.core.tick(); + (self.emit(step), self.core.is_idle()) + } + pub(crate) fn on_commit_tick_batch( &mut self, max_lines: usize, ) -> (Option>, bool) { - let step = self.state.drain_n(max_lines.max(1)); - (self.emit(step), self.state.is_idle()) + let step = self.core.tick_batch(max_lines); + (self.emit(step), self.core.is_idle()) } - /// Returns the current number of queued lines waiting to be displayed. pub(crate) fn queued_lines(&self) -> usize { - self.state.queued_len() + self.core.queued_lines() } - /// Returns the age of the oldest queued line. pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option { - self.state.oldest_queued_age(now) + self.core.oldest_queued_age(now) + } + + pub(crate) fn clear_queue(&mut self) { + self.core.clear_queue(); + } + + pub(crate) fn set_width(&mut self, width: Option) { + self.core.set_width(width); } fn emit(&mut self, lines: Vec>) -> Option> { @@ -112,96 +301,88 @@ impl StreamController { } } -/// Controller that streams proposed plan markdown into a styled plan block. +/// Controls newline-gated streaming for proposed plan markdown. +/// +/// This follows the same source-retention contract as `StreamController`, but wraps emitted lines +/// in the proposed-plan header, padding, and style. Finalization must return source for +/// `ProposedPlanCell`; otherwise a resized finalized plan would keep the transient stream shape. pub(crate) struct PlanStreamController { - state: StreamState, + core: StreamCore, header_emitted: bool, top_padding_emitted: bool, } impl PlanStreamController { - /// Create a plan-stream controller whose markdown renderer shortens local file links relative - /// to `cwd`. + /// Create a proposed-plan stream controller that renders markdown relative to the given cwd. /// - /// The controller snapshots the path into stream state so later commit ticks and finalization - /// render against the same session cwd that was active when streaming started. + /// The width has the same meaning as in `StreamController`: it is the markdown body width, and + /// callers must update it when the terminal width changes. pub(crate) fn new(width: Option, cwd: &Path) -> Self { Self { - state: StreamState::new(width, cwd), + core: StreamCore::new(width, cwd), header_emitted: false, top_padding_emitted: false, } } - /// Push a delta; if it contains a newline, commit completed lines and start animation. + /// Push a raw proposed-plan delta and return whether it produced queued complete lines. + /// + /// Source may be buffered even when this returns `false`; callers should continue ticking only + /// when queued lines exist. pub(crate) fn push(&mut self, delta: &str) -> bool { - let state = &mut self.state; - if !delta.is_empty() { - state.has_seen_delta = true; - } - state.collector.push_delta(delta); - if delta.contains('\n') { - let newly_completed = state.collector.commit_complete_lines(); - if !newly_completed.is_empty() { - state.enqueue(newly_completed); - return true; - } - } - false + self.core.push_delta(delta) } - /// Finalize the active stream. Drain and emit now. - pub(crate) fn finalize(&mut self) -> Option> { - let remaining = { - let state = &mut self.state; - state.collector.finalize_and_drain() - }; - let mut out_lines = Vec::new(); - { - let state = &mut self.state; - if !remaining.is_empty() { - state.enqueue(remaining); - } - let step = state.drain_all(); - out_lines.extend(step); + /// Finish the plan stream and return the final transient cell plus accumulated markdown source. + /// + /// The returned source is consumed by app-level consolidation to create the source-backed + /// `ProposedPlanCell` used for later resize reflow. + pub(crate) fn finalize(&mut self) -> (Option>, Option) { + let remaining = self.core.finalize_remaining(); + if self.core.raw_source.is_empty() { + self.core.reset(); + return (None, None); } - self.state.clear(); - self.emit(out_lines, /*include_bottom_padding*/ true) + let source = std::mem::take(&mut self.core.raw_source); + let out = self.emit(remaining, /*include_bottom_padding*/ true); + self.core.reset(); + (out, Some(source)) } - /// Step animation: commit at most one queued line and handle end-of-drain cleanup. pub(crate) fn on_commit_tick(&mut self) -> (Option>, bool) { - let step = self.state.step(); + let step = self.core.tick(); ( self.emit(step, /*include_bottom_padding*/ false), - self.state.is_idle(), + self.core.is_idle(), ) } - /// Step animation: commit at most `max_lines` queued lines. - /// - /// This is intended for adaptive catch-up drains. Callers should keep `max_lines` bounded; a - /// very large value can collapse perceived animation into a single jump. pub(crate) fn on_commit_tick_batch( &mut self, max_lines: usize, ) -> (Option>, bool) { - let step = self.state.drain_n(max_lines.max(1)); + let step = self.core.tick_batch(max_lines); ( self.emit(step, /*include_bottom_padding*/ false), - self.state.is_idle(), + self.core.is_idle(), ) } - /// Returns the current number of queued plan lines waiting to be displayed. pub(crate) fn queued_lines(&self) -> usize { - self.state.queued_len() + self.core.queued_lines() } - /// Returns the age of the oldest queued plan line. pub(crate) fn oldest_queued_age(&self, now: Instant) -> Option { - self.state.oldest_queued_age(now) + self.core.oldest_queued_age(now) + } + + pub(crate) fn clear_queue(&mut self) { + self.core.clear_queue(); + } + + pub(crate) fn set_width(&mut self, width: Option) { + self.core.set_width(width); } fn emit( @@ -213,7 +394,7 @@ impl PlanStreamController { return None; } - let mut out_lines: Vec> = Vec::new(); + let mut out_lines: Vec> = Vec::with_capacity(4); let is_stream_continuation = self.header_emitted; if !self.header_emitted { out_lines.push(vec!["• ".dim(), "Proposed Plan".bold()].into()); @@ -221,7 +402,7 @@ impl PlanStreamController { self.header_emitted = true; } - let mut plan_lines: Vec> = Vec::new(); + let mut plan_lines: Vec> = Vec::with_capacity(4); if !self.top_padding_emitted { plan_lines.push(Line::from(" ")); self.top_padding_emitted = true; @@ -248,106 +429,37 @@ impl PlanStreamController { #[cfg(test)] mod tests { use super::*; - use std::path::PathBuf; + use pretty_assertions::assert_eq; fn test_cwd() -> PathBuf { - // These tests only need a stable absolute cwd; using temp_dir() avoids baking Unix- or - // Windows-specific root semantics into the fixtures. std::env::temp_dir() } - fn lines_to_plain_strings(lines: &[ratatui::text::Line<'_>]) -> Vec { + fn stream_controller(width: Option) -> StreamController { + StreamController::new(width, &test_cwd()) + } + + fn plan_stream_controller(width: Option) -> PlanStreamController { + PlanStreamController::new(width, &test_cwd()) + } + + fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec { lines .iter() - .map(|l| { - l.spans + .map(|line| { + line.spans .iter() - .map(|s| s.content.clone()) - .collect::>() - .join("") + .map(|span| span.content.clone()) + .collect::() }) .collect() } - #[tokio::test] - async fn controller_loose_vs_tight_with_commit_ticks_matches_full() { - let mut ctrl = StreamController::new(/*width*/ None, &test_cwd()); + fn collect_streamed_lines(deltas: &[&str], width: Option) -> Vec { + let mut ctrl = stream_controller(width); let mut lines = Vec::new(); - - // Exact deltas from the session log (section: Loose vs. tight list items) - let deltas = vec![ - "\n\n", - "Loose", - " vs", - ".", - " tight", - " list", - " items", - ":\n", - "1", - ".", - " Tight", - " item", - "\n", - "2", - ".", - " Another", - " tight", - " item", - "\n\n", - "1", - ".", - " Loose", - " item", - " with", - " its", - " own", - " paragraph", - ".\n\n", - " ", - " This", - " paragraph", - " belongs", - " to", - " the", - " same", - " list", - " item", - ".\n\n", - "2", - ".", - " Second", - " loose", - " item", - " with", - " a", - " nested", - " list", - " after", - " a", - " blank", - " line", - ".\n\n", - " ", - " -", - " Nested", - " bullet", - " under", - " a", - " loose", - " item", - "\n", - " ", - " -", - " Another", - " nested", - " bullet", - "\n\n", - ]; - - // Simulate streaming with a commit tick attempt after each delta. - for d in deltas.iter() { - ctrl.push(d); + for delta in deltas { + ctrl.push(delta); while let (Some(cell), idle) = ctrl.on_commit_tick() { lines.extend(cell.transcript_lines(u16::MAX)); if idle { @@ -355,47 +467,122 @@ mod tests { } } } - // Finalize and flush remaining lines now. - if let Some(cell) = ctrl.finalize() { + if let (Some(cell), _source) = ctrl.finalize() { lines.extend(cell.transcript_lines(u16::MAX)); } - - let streamed: Vec<_> = lines_to_plain_strings(&lines) + lines_to_plain_strings(&lines) .into_iter() - // skip • and 2-space indentation - .map(|s| s.chars().skip(2).collect::()) - .collect(); + .map(|line| line.chars().skip(2).collect::()) + .collect() + } - // Full render of the same source - let source: String = deltas.iter().copied().collect(); - let mut rendered: Vec> = Vec::new(); - let test_cwd = test_cwd(); - crate::markdown::append_markdown( - &source, - /*width*/ None, - Some(test_cwd.as_path()), - &mut rendered, + fn collect_plan_streamed_lines(deltas: &[&str], width: Option) -> Vec { + let mut ctrl = plan_stream_controller(width); + let mut lines = Vec::new(); + for delta in deltas { + ctrl.push(delta); + while let (Some(cell), idle) = ctrl.on_commit_tick() { + lines.extend(cell.transcript_lines(u16::MAX)); + if idle { + break; + } + } + } + if let (Some(cell), _source) = ctrl.finalize() { + lines.extend(cell.transcript_lines(u16::MAX)); + } + lines_to_plain_strings(&lines) + } + + #[test] + fn controller_set_width_rebuilds_queued_lines() { + let mut ctrl = stream_controller(Some(120)); + let delta = "This is a long line that should wrap into multiple rows when resized.\n"; + assert!(ctrl.push(delta)); + assert_eq!(ctrl.queued_lines(), 1); + + ctrl.set_width(Some(24)); + let (cell, idle) = ctrl.on_commit_tick_batch(usize::MAX); + let rendered = lines_to_plain_strings( + &cell + .expect("expected resized queued lines") + .transcript_lines(u16::MAX), ); - let rendered_strs = lines_to_plain_strings(&rendered); - assert_eq!(streamed, rendered_strs); + assert!(idle); + assert!( + rendered.len() > 1, + "expected resized content to occupy multiple lines, got {rendered:?}", + ); + } + + #[test] + fn controller_set_width_no_duplicate_after_emit() { + let mut ctrl = stream_controller(Some(120)); + let line = + "This is a long line that definitely wraps when the terminal shrinks to 24 columns.\n"; + ctrl.push(line); + let (cell, _) = ctrl.on_commit_tick_batch(usize::MAX); + assert!(cell.is_some(), "expected emitted cell"); + assert_eq!(ctrl.queued_lines(), 0); + + ctrl.set_width(Some(24)); - // Also assert exact expected plain strings for clarity. - let expected = vec![ - "Loose vs. tight list items:".to_string(), - "".to_string(), - "1. Tight item".to_string(), - "2. Another tight item".to_string(), - "3. Loose item with its own paragraph.".to_string(), - "".to_string(), - " This paragraph belongs to the same list item.".to_string(), - "4. Second loose item with a nested list after a blank line.".to_string(), - " - Nested bullet under a loose item".to_string(), - " - Another nested bullet".to_string(), - ]; assert_eq!( - streamed, expected, - "expected exact rendered lines for loose/tight section" + ctrl.queued_lines(), + 0, + "already-emitted content must not be re-queued after resize", + ); + } + + #[test] + fn controller_tick_batch_zero_is_noop() { + let mut ctrl = stream_controller(Some(80)); + assert!(ctrl.push("line one\n")); + assert_eq!(ctrl.queued_lines(), 1); + + let (cell, idle) = ctrl.on_commit_tick_batch(/*max_lines*/ 0); + assert!(cell.is_none(), "batch size 0 should not emit lines"); + assert!(!idle, "batch size 0 should not drain queued lines"); + assert_eq!( + ctrl.queued_lines(), + 1, + "queue depth should remain unchanged" + ); + } + + #[test] + fn controller_finalize_returns_raw_source_for_consolidation() { + let mut ctrl = stream_controller(Some(80)); + assert!(ctrl.push("hello\n")); + let (_cell, source) = ctrl.finalize(); + assert_eq!(source, Some("hello\n".to_string())); + } + + #[test] + fn plan_controller_finalize_returns_raw_source_for_consolidation() { + let mut ctrl = plan_stream_controller(Some(80)); + assert!(ctrl.push("- step\n")); + let (_cell, source) = ctrl.finalize(); + assert_eq!(source, Some("- step\n".to_string())); + } + + #[test] + fn simple_lines_stream_in_order() { + let actual = collect_streamed_lines(&["hello\n", "world\n"], Some(80)); + assert_eq!(actual, vec!["hello".to_string(), "world".to_string()]); + } + + #[test] + fn plan_lines_stream_in_order() { + let actual = collect_plan_streamed_lines(&["- one\n", "- two\n"], Some(80)); + assert!( + actual.iter().any(|line| line.contains("Proposed Plan")), + "expected plan header in streamed plan: {actual:?}", + ); + assert!( + actual.iter().any(|line| line.contains("one")), + "expected plan body in streamed plan: {actual:?}", ); } } diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index ae3b68742..ddbac2e4c 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -70,12 +70,9 @@ impl StreamState { .map(|queued| queued.line) .collect() } - /// Drains all queued lines from the front of the queue. - pub(crate) fn drain_all(&mut self) -> Vec> { - self.queued_lines - .drain(..) - .map(|queued| queued.line) - .collect() + /// Clears queued lines while keeping collector/turn lifecycle state intact. + pub(crate) fn clear_queue(&mut self) { + self.queued_lines.clear(); } /// Returns whether no lines are queued for commit. pub(crate) fn is_idle(&self) -> bool { diff --git a/codex-rs/tui/src/transcript_reflow.rs b/codex-rs/tui/src/transcript_reflow.rs new file mode 100644 index 000000000..33318a4d0 --- /dev/null +++ b/codex-rs/tui/src/transcript_reflow.rs @@ -0,0 +1,302 @@ +//! Tracks when Codex-owned transcript scrollback must be repaired after terminal resize. +//! +//! Terminal scrollback is not a retained widget tree: once Codex writes wrapped lines into the +//! terminal, the terminal owns those rows. Width resize reflow treats the in-memory transcript cells +//! as the source of truth, clears Codex-owned history, and re-emits the cells at the current width. +//! Height-only growth also schedules a rebuild so rows exposed above the inline viewport are +//! restored from the same source of truth. +//! +//! This module owns only scheduling and stream-time repair state. It does not know how to render +//! cells or clear terminal output; `app::resize_reflow` consumes this state and performs the +//! rebuild. The key invariant is that a reflow request which happens while streaming output is +//! active, or while transient stream cells are still waiting for consolidation, must trigger one +//! final source-backed reflow after the stream becomes source-backed history. + +use std::time::Duration; +use std::time::Instant; + +pub(crate) const TRANSCRIPT_REFLOW_DEBOUNCE: Duration = Duration::from_millis(75); + +/// Tracks pending terminal-scrollback repair after a terminal resize. +/// +/// The state intentionally separates observed terminal width from rebuilt terminal width. Terminal +/// emulators can report an intermediate size during drag-resize, then settle on the final size after +/// Codex has already rebuilt scrollback. Keeping those widths distinct lets the next draw request a +/// final rebuild instead of assuming the latest observed size has already been repaired. +#[derive(Debug, Default)] +pub(crate) struct TranscriptReflowState { + last_observed_width: Option, + last_reflow_width: Option, + pending_reflow_width: Option, + pending_until: Option, + ran_during_stream: bool, + resize_requested_during_stream: bool, +} + +impl TranscriptReflowState { + /// Reset all width, pending deadline, and stream repair state. + /// + /// Call this when resize reflow is disabled or when the app discards the transcript state that + /// pending reflow work would have rebuilt. Leaving stale deadlines behind would make a later + /// draw attempt to rebuild history from unrelated cells. + pub(crate) fn clear(&mut self) { + *self = Self::default(); + } + + /// Record the width observed during a draw and report whether it is new or changed. + /// + /// The first observed width initializes the state without scheduling a rebuild because no + /// old-width transcript has been emitted yet. Treating initialization as a real resize would + /// make the first draw do redundant scrollback work. + pub(crate) fn note_width(&mut self, width: u16) -> TranscriptWidthChange { + let previous_width = self.last_observed_width.replace(width); + if previous_width.is_none() { + self.last_reflow_width = Some(width); + } + TranscriptWidthChange { + changed: previous_width.is_some_and(|previous| previous != width), + initialized: previous_width.is_none(), + } + } + + /// Return whether scrollback still needs to be rebuilt at `width`. + /// + /// This compares against the width that actually rebuilt scrollback, not just the most recently + /// observed terminal width. A terminal can report the final size after the reflow that handled + /// the resize event, so the follow-up draw must be able to request one more reflow even if + /// the observed-width tracker already saw that value. + pub(crate) fn reflow_needed_for_width(&self, width: u16) -> bool { + self.last_reflow_width != Some(width) && self.pending_reflow_width != Some(width) + } + + /// Schedule a trailing-debounced reflow and return whether it should run immediately. + /// + /// Repeated resize events push the deadline out so dragging a terminal edge rebuilds scrollback + /// at the final observed width rather than at intermediate widths. `target_width` is present + /// only for width-changing rebuilds; height-only exposure still needs a rebuild, but it must not + /// suppress a later width repair for the same draw cycle. + pub(crate) fn schedule_debounced(&mut self, target_width: Option) -> bool { + let now = Instant::now(); + if let Some(target_width) = target_width { + self.pending_reflow_width = Some(target_width); + } + self.pending_until = Some(now + TRANSCRIPT_REFLOW_DEBOUNCE); + false + } + + /// Schedule an immediate reflow for the next draw opportunity. + /// + /// This is used after stream consolidation when waiting for the debounce interval would leave + /// visible terminal-wrapped stream rows in the finalized transcript. + pub(crate) fn schedule_immediate(&mut self) { + self.pending_reflow_width = None; + self.pending_until = Some(Instant::now()); + } + + #[cfg(test)] + pub(crate) fn set_due_for_test(&mut self) { + self.pending_until = Some(Instant::now() - Duration::from_millis(1)); + } + + pub(crate) fn pending_is_due(&self, now: Instant) -> bool { + self.pending_until.is_some_and(|deadline| now >= deadline) + } + + pub(crate) fn pending_until(&self) -> Option { + self.pending_until + } + + pub(crate) fn has_pending_reflow(&self) -> bool { + self.pending_until.is_some() + } + + pub(crate) fn clear_pending_reflow(&mut self) { + self.pending_until = None; + self.pending_reflow_width = None; + } + + /// Remember the terminal width that actually rebuilt transcript scrollback. + /// + /// Resize scheduling is driven by observed widths, but debounced redraws may run before a + /// terminal emulator has settled on its final size. Keeping the rendered width separate avoids + /// confusing "seen during a draw" with "scrollback has been repaired at this width". + pub(crate) fn mark_reflowed_width(&mut self, width: u16) -> bool { + self.last_reflow_width.replace(width) != Some(width) + } + + /// Remember that a reflow actually rebuilt history before stream consolidation completed. + /// + /// A mid-stream rebuild can only render the transient stream cells that exist at that moment. + /// The consolidation handler must later rebuild again from the finalized source-backed cell or + /// the transcript can keep old stream wrapping. + pub(crate) fn mark_ran_during_stream(&mut self) { + self.ran_during_stream = true; + } + + /// Remember that the terminal width changed while streaming or pre-consolidation cells existed. + /// + /// This captures the case where the debounce did not fire before the stream finished. Without + /// this flag, consolidation could complete without the final source-backed resize repair. + /// Marking the request rather than forcing immediate rendering keeps resize drag behavior + /// debounced while still guaranteeing that finalized stream cells replace transient rows. + pub(crate) fn mark_resize_requested_during_stream(&mut self) { + self.resize_requested_during_stream = true; + } + + /// Return whether stream finalization needs a source-backed reflow and clear the request. + /// + /// This is a draining read because each resize-during-stream episode should force at most one + /// post-consolidation repair. Calling it before consolidation would drop the repair request and + /// leave finalized scrollback shaped by transient stream rows. + pub(crate) fn take_stream_finish_reflow_needed(&mut self) -> bool { + let needed = self.ran_during_stream || self.resize_requested_during_stream; + self.ran_during_stream = false; + self.resize_requested_during_stream = false; + needed + } + + /// Clear only the stream repair flags while preserving width and pending-deadline state. + /// + /// Use this after a required final stream reflow has completed. Calling `clear()` here would + /// also forget the last observed width and make the next draw look like first initialization. + pub(crate) fn clear_stream_flags(&mut self) { + self.ran_during_stream = false; + self.resize_requested_during_stream = false; + } +} + +/// Describes how the latest draw width relates to the previous observed draw width. +/// +/// `initialized` means this was the first width observed by the state machine. `changed` means a +/// previously observed transcript width exists and differs from the new width. +pub(crate) struct TranscriptWidthChange { + pub(crate) changed: bool, + pub(crate) initialized: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schedule_debounced_postpones_existing_reflow() { + let mut state = TranscriptReflowState::default(); + + assert!(!state.schedule_debounced(/*target_width*/ None)); + let first_deadline = state.pending_until().expect("pending reflow"); + + std::thread::sleep(Duration::from_millis(1)); + assert!(!state.schedule_debounced(/*target_width*/ None)); + + assert!( + state.pending_until().expect("pending reflow") > first_deadline, + "a later resize should push the debounce deadline out" + ); + } + + #[test] + fn schedule_debounced_postpones_due_existing_reflow() { + let mut state = TranscriptReflowState::default(); + state.set_due_for_test(); + let before_reschedule = Instant::now(); + + assert!(!state.schedule_debounced(/*target_width*/ None)); + assert!( + state.pending_until().expect("pending reflow") > before_reschedule, + "a resize after the old deadline should start a fresh quiet period" + ); + } + + #[test] + fn first_observed_width_marks_reflow_baseline() { + let mut state = TranscriptReflowState::default(); + + let width = state.note_width(/*width*/ 80); + + assert!(width.initialized); + assert_eq!(state.last_observed_width, Some(80)); + assert_eq!(state.last_reflow_width, Some(80)); + assert!(!state.reflow_needed_for_width(/*width*/ 80)); + } + + #[test] + fn mark_reflowed_width_records_actual_rebuild_width() { + let mut state = TranscriptReflowState::default(); + state.note_width(/*width*/ 80); + + assert!(state.mark_reflowed_width(/*width*/ 100)); + + assert_eq!(state.last_observed_width, Some(80)); + assert_eq!(state.last_reflow_width, Some(100)); + } + + #[test] + fn reflow_needed_compares_against_actual_rebuild_width() { + let mut state = TranscriptReflowState::default(); + state.note_width(/*width*/ 80); + state.mark_reflowed_width(/*width*/ 90); + state.note_width(/*width*/ 100); + + assert!(state.reflow_needed_for_width(/*width*/ 100)); + } + + #[test] + fn pending_reflow_target_prevents_repeated_reschedule() { + let mut state = TranscriptReflowState::default(); + state.note_width(/*width*/ 80); + + assert!(state.reflow_needed_for_width(/*width*/ 100)); + state.schedule_debounced(/*target_width*/ Some(100)); + + assert!(!state.reflow_needed_for_width(/*width*/ 100)); + } + + #[test] + fn clear_pending_reflow_allows_same_width_to_be_rescheduled() { + let mut state = TranscriptReflowState::default(); + state.note_width(/*width*/ 80); + state.schedule_debounced(/*target_width*/ Some(100)); + + state.clear_pending_reflow(); + + assert!(state.reflow_needed_for_width(/*width*/ 100)); + } + + #[test] + fn mark_reflowed_width_reports_unchanged_width() { + let mut state = TranscriptReflowState::default(); + assert!(state.mark_reflowed_width(/*width*/ 100)); + + assert!(!state.mark_reflowed_width(/*width*/ 100)); + assert_eq!(state.last_reflow_width, Some(100)); + } + + #[test] + fn take_stream_finish_reflow_needed_drains_resize_request() { + let mut state = TranscriptReflowState::default(); + state.mark_resize_requested_during_stream(); + + assert!(state.take_stream_finish_reflow_needed()); + assert!(!state.take_stream_finish_reflow_needed()); + } + + #[test] + fn take_stream_finish_reflow_needed_drains_ran_during_stream() { + let mut state = TranscriptReflowState::default(); + state.mark_ran_during_stream(); + + assert!(state.take_stream_finish_reflow_needed()); + assert!(!state.take_stream_finish_reflow_needed()); + } + + #[test] + fn clear_resets_stream_reflow_flags() { + let mut state = TranscriptReflowState::default(); + state.mark_ran_during_stream(); + state.mark_resize_requested_during_stream(); + + state.clear(); + + assert!(!state.take_stream_finish_reflow_needed()); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 291a8ca63..79e4cf7d2 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -31,6 +31,7 @@ use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; use ratatui::layout::Offset; +use ratatui::layout::Position; use ratatui::layout::Rect; use ratatui::layout::Size; use ratatui::text::Line; @@ -108,7 +109,7 @@ fn running_in_wsl() -> bool { } } -fn running_in_vscode_terminal() -> bool { +pub(crate) fn running_in_vscode_terminal() -> bool { vscode_terminal_detected( std::env::var("TERM_PROGRAM").ok().as_deref(), windows_term_program().as_deref(), @@ -443,8 +444,16 @@ fn set_panic_hook() { #[derive(Clone, Debug)] pub enum TuiEvent { + /// A terminal key event after focus, paste, and protocol bookkeeping has been handled. Key(KeyEvent), + /// A bracketed paste payload normalized by the app layer before it reaches the composer. Paste(String), + /// A terminal size notification that should be handled as resize-sensitive draw work. + /// + /// Resize is separate from `Draw` so the app can run feature-gated pre-render logic without + /// changing the default draw path for scheduled frames. + Resize, + /// A scheduled repaint that does not necessarily correspond to a terminal size change. Draw, } @@ -729,6 +738,54 @@ impl Tui { Ok(()) } + /// Resize the inline viewport for the resize-reflow path. + /// + /// Unlike the legacy draw path, this path does not scroll rows above the viewport when the + /// terminal shrinks. Resize reflow owns rebuilding those rows from transcript source, so + /// scrolling here would move the viewport once and then replay history into the wrong row. + fn update_inline_viewport_for_resize_reflow( + terminal: &mut Terminal, + height: u16, + is_zellij: bool, + ) -> Result { + let size = terminal.size()?; + let terminal_height_shrank = size.height < terminal.last_known_screen_size.height; + let terminal_height_grew = size.height > terminal.last_known_screen_size.height; + let viewport_was_bottom_aligned = + terminal.viewport_area.bottom() == terminal.last_known_screen_size.height; + let previous_area = terminal.viewport_area; + + let mut area = terminal.viewport_area; + area.height = height.min(size.height); + area.width = size.width; + let mut needs_full_repaint = false; + + if area.bottom() > size.height { + let scroll_by = area.bottom() - size.height; + if !terminal_height_shrank { + if is_zellij { + Self::scroll_zellij_expanded_viewport(terminal, size, scroll_by)?; + } else { + terminal + .backend_mut() + .scroll_region_up(0..area.top(), scroll_by)?; + } + } + area.y = size.height - area.height; + } else if terminal_height_grew && viewport_was_bottom_aligned { + area.y = size.height - area.height; + } + + if area != terminal.viewport_area { + let clear_position = Position::new(/*x*/ 0, previous_area.y.min(area.y)); + terminal.set_viewport_area(area); + terminal.clear_after_position(clear_position)?; + needs_full_repaint = true; + } + + Ok(needs_full_repaint) + } + /// 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. @@ -810,6 +867,63 @@ impl Tui { })? } + /// Draw a frame using the resize-reflow viewport and history insertion rules. + /// + /// This is the feature-gated counterpart to `draw`. It intentionally skips + /// `pending_viewport_area`, whose cursor-position heuristic is part of the legacy path, and + /// instead lets transcript reflow rebuild scrollback before the frame is rendered. + pub fn draw_with_resize_reflow( + &mut self, + height: u16, + draw_fn: impl FnOnce(&mut custom_terminal::Frame), + ) -> Result<()> { + // If we are resuming from ^Z, we need to prepare the resume action now so we can apply it + // in the synchronized update. + #[cfg(unix)] + let mut prepared_resume = self + .suspend_context + .prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport); + + stdout().sync_update(|_| { + #[cfg(unix)] + if let Some(prepared) = prepared_resume.take() { + prepared.apply(&mut self.terminal)?; + } + + let terminal = &mut self.terminal; + let mut needs_full_repaint = + Self::update_inline_viewport_for_resize_reflow(terminal, height, self.is_zellij)?; + let flushed_history = Self::flush_pending_history_lines( + terminal, + &mut self.pending_history_lines, + self.is_zellij, + )?; + needs_full_repaint |= flushed_history; + + if needs_full_repaint { + terminal.invalidate_viewport(); + } + + // Update the y position for suspending so Ctrl-Z can place the cursor correctly. + #[cfg(unix)] + { + let area = terminal.viewport_area; + let inline_area_bottom = if self.alt_screen_active.load(Ordering::Relaxed) { + self.alt_saved_viewport + .map(|r| r.bottom().saturating_sub(1)) + .unwrap_or_else(|| area.bottom().saturating_sub(1)) + } else { + area.bottom().saturating_sub(1) + }; + self.suspend_context.set_cursor_y(inline_area_bottom); + } + + terminal.draw(|frame| { + draw_fn(frame); + }) + })? + } + fn pending_viewport_area(&mut self) -> Result> { let terminal = &mut self.terminal; let screen_size = terminal.size()?; diff --git a/codex-rs/tui/src/tui/event_stream.rs b/codex-rs/tui/src/tui/event_stream.rs index 2ce0aa7d2..dcc6e17e0 100644 --- a/codex-rs/tui/src/tui/event_stream.rs +++ b/codex-rs/tui/src/tui/event_stream.rs @@ -244,7 +244,7 @@ impl TuiEventStream { } Some(TuiEvent::Key(key_event)) } - Event::Resize(_, _) => Some(TuiEvent::Draw), + Event::Resize(_, _) => Some(TuiEvent::Resize), Event::Paste(pasted) => Some(TuiEvent::Paste(pasted)), Event::FocusGained => { self.terminal_focused.store(true, Ordering::Relaxed); @@ -451,6 +451,17 @@ mod tests { assert!(matches!(first, Some(TuiEvent::Draw))); } + #[tokio::test(flavor = "current_thread")] + async fn resize_event_maps_to_resize() { + let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); + let mut stream = make_stream(broker, draw_rx, terminal_focused); + + handle.send(Ok(Event::Resize(80, 24))); + + let next = stream.next().await; + assert!(matches!(next, Some(TuiEvent::Resize))); + } + #[tokio::test(flavor = "current_thread")] async fn error_or_eof_ends_stream() { let (broker, handle, _draw_tx, draw_rx, terminal_focused) = setup(); diff --git a/codex-rs/tui/src/update_prompt.rs b/codex-rs/tui/src/update_prompt.rs index ab9c93f42..4d5a9e128 100644 --- a/codex-rs/tui/src/update_prompt.rs +++ b/codex-rs/tui/src/update_prompt.rs @@ -57,7 +57,7 @@ pub(crate) async fn run_update_prompt_if_needed( match event { TuiEvent::Key(key_event) => screen.handle_key(key_event), TuiEvent::Paste(_) => {} - TuiEvent::Draw => { + TuiEvent::Draw | TuiEvent::Resize => { tui.draw(u16::MAX, |frame| { frame.render_widget_ref(&screen, frame.area()); })?; diff --git a/codex-rs/tui/src/width.rs b/codex-rs/tui/src/width.rs new file mode 100644 index 000000000..a69cddb27 --- /dev/null +++ b/codex-rs/tui/src/width.rs @@ -0,0 +1,72 @@ +//! Width guards for transcript rendering with fixed prefix columns. +//! +//! Several rendering paths reserve a fixed number of columns for bullets, +//! gutters, or labels before laying out content. When the terminal is very +//! narrow, those reserved columns can consume the entire width, leaving zero +//! or negative space for content. +//! +//! These helpers centralise the subtraction and enforce a strict-positive +//! contract: they return `Some(n)` where `n > 0`, or `None` when no usable +//! content width remains. Callers treat `None` as "render prefix-only +//! fallback" rather than attempting wrapped rendering at zero width, which +//! would produce empty or unstable output. + +/// Returns usable content width after reserving fixed columns. +/// +/// Guarantees a strict positive width (`Some(n)` where `n > 0`) or `None` when +/// the reserved columns consume the full width. +/// +/// Treat `None` as "render prefix-only fallback". Coercing it to `0` and still +/// attempting wrapped rendering often produces empty or unstable output at very +/// narrow terminal widths. +pub(crate) fn usable_content_width(total_width: usize, reserved_cols: usize) -> Option { + total_width + .checked_sub(reserved_cols) + .filter(|remaining| *remaining > 0) +} + +/// `u16` convenience wrapper around [`usable_content_width`]. +/// +/// This keeps width math at callsites that receive terminal dimensions as +/// `u16` while preserving the same `None` contract for exhausted width. +pub(crate) fn usable_content_width_u16(total_width: u16, reserved_cols: u16) -> Option { + usable_content_width(usize::from(total_width), usize::from(reserved_cols)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn usable_content_width_returns_none_when_reserved_exhausts_width() { + assert_eq!( + usable_content_width(/*total_width*/ 0, /*reserved_cols*/ 0), + None + ); + assert_eq!( + usable_content_width(/*total_width*/ 2, /*reserved_cols*/ 2), + None + ); + assert_eq!( + usable_content_width(/*total_width*/ 3, /*reserved_cols*/ 4), + None + ); + assert_eq!( + usable_content_width(/*total_width*/ 5, /*reserved_cols*/ 4), + Some(1) + ); + } + + #[test] + fn usable_content_width_u16_matches_usize_variant() { + assert_eq!( + usable_content_width_u16(/*total_width*/ 2, /*reserved_cols*/ 2), + None + ); + assert_eq!( + usable_content_width_u16(/*total_width*/ 5, /*reserved_cols*/ 4), + Some(1) + ); + } +} diff --git a/codex-rs/tui/tests/suite/mod.rs b/codex-rs/tui/tests/suite/mod.rs index c31326b10..b205ead32 100644 --- a/codex-rs/tui/tests/suite/mod.rs +++ b/codex-rs/tui/tests/suite/mod.rs @@ -1,6 +1,7 @@ // Aggregates all former standalone integration tests as modules. mod model_availability_nux; mod no_panic_on_startup; +mod resize_reflow; mod status_indicator; mod vt100_history; mod vt100_live_commit; diff --git a/codex-rs/tui/tests/suite/resize_reflow.rs b/codex-rs/tui/tests/suite/resize_reflow.rs new file mode 100644 index 000000000..53c1c5da9 --- /dev/null +++ b/codex-rs/tui/tests/suite/resize_reflow.rs @@ -0,0 +1,613 @@ +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use std::process::Output; +use std::thread::sleep; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use tempfile::tempdir; + +#[test] +#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] +fn tmux_split_preserves_fresh_session_composer_row_after_resize_reflow() -> Result<()> { + if cfg!(windows) { + return Ok(()); + } + if Command::new("tmux").arg("-V").output().is_err() { + eprintln!("skipping resize smoke because tmux is unavailable"); + return Ok(()); + } + + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex = codex_binary(&repo_root)?; + let codex_home = tempdir()?; + let fixture_dir = tempdir()?; + let fixture = fixture_dir.path().join("resize-reflow.sse"); + write_fixture(&fixture)?; + write_config( + codex_home.path(), + &repo_root, + /*terminal_resize_reflow_enabled*/ true, + )?; + write_auth(codex_home.path())?; + + let session_name = format!("codex-resize-reflow-smoke-{}", std::process::id()); + let _session = TmuxSession { + name: session_name.clone(), + }; + + let prompt = "Say hi."; + let start_output = checked_output( + Command::new("tmux") + .arg("new-session") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-x") + .arg("120") + .arg("-y") + .arg("40") + .arg("-s") + .arg(&session_name) + .arg("--") + .arg("env") + .arg(format!("CODEX_HOME={}", codex_home.path().display())) + .arg("OPENAI_API_KEY=dummy") + .arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display())) + .arg(codex) + .arg("-c") + .arg("analytics.enabled=false") + .arg("--no-alt-screen") + .arg("-C") + .arg(&repo_root) + .arg(prompt), + )?; + let codex_pane = stdout_text(&start_output).trim().to_string(); + anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); + + wait_for_capture_contains( + &codex_pane, + "resize reflow sentinel", + Duration::from_secs(/*secs*/ 15), + )?; + wait_for_capture_contains( + &codex_pane, + "gpt-5.4 default", + Duration::from_secs(/*secs*/ 15), + )?; + let draft = "Notice where we are here in terms of y location."; + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(draft), + )?; + let baseline_capture = + wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; + let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; + let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") + .context("history row before split")?; + + let split_output = checked_output( + Command::new("tmux") + .arg("split-window") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-v") + .arg("-l") + .arg("12") + .arg("-t") + .arg(&codex_pane) + .arg("sleep") + .arg("30"), + )?; + let split_pane = stdout_text(&split_output).trim().to_string(); + + sleep(Duration::from_millis(/*millis*/ 250)); + let first_capture = capture_pane(&codex_pane)?; + let first_row = last_composer_row(&first_capture).context("composer row after split")?; + + sleep(Duration::from_millis(/*millis*/ 1_000)); + let second_capture = capture_pane(&codex_pane)?; + let second_row = + last_composer_row(&second_capture).context("composer row after reflow wait")?; + + anyhow::ensure!( + first_row == second_row, + "composer row drifted after split: before={first_row}, after={second_row}\n\ + before:\n{first_capture}\n\ + after:\n{second_capture}" + ); + anyhow::ensure!( + second_row <= baseline_row + 1, + "composer row snapped downward after split: baseline={baseline_row}, after={second_row}\n\ + baseline:\n{baseline_capture}\n\ + after:\n{second_capture}" + ); + + check( + Command::new("tmux") + .arg("kill-pane") + .arg("-t") + .arg(&split_pane), + )?; + + sleep(Duration::from_millis(/*millis*/ 500)); + let final_capture = capture_pane(&codex_pane)?; + let final_row = + last_composer_row(&final_capture).context("composer row after closing split")?; + anyhow::ensure!( + final_row == baseline_row, + "composer row drifted after closing split: baseline={baseline_row}, after={final_row}\n\ + capture:\n{final_capture}" + ); + let final_history_row = first_row_containing(&final_capture, "resize reflow sentinel") + .context("history row after closing split")?; + anyhow::ensure!( + final_history_row == baseline_history_row, + "history row drifted after closing split: baseline={baseline_history_row}, \ + after={final_history_row}\n\ + baseline:\n{baseline_capture}\n\ + after:\n{final_capture}" + ); + + Ok(()) +} + +#[test] +#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] +fn tmux_repeated_resizes_do_not_push_composer_down() -> Result<()> { + if cfg!(windows) { + return Ok(()); + } + if Command::new("tmux").arg("-V").output().is_err() { + eprintln!("skipping resize smoke because tmux is unavailable"); + return Ok(()); + } + + run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ false)?; + run_repeated_resize_smoke(/*terminal_resize_reflow_enabled*/ true)?; + + Ok(()) +} + +#[test] +#[ignore = "requires tmux and a locally built codex binary; run with --ignored for manual resize smoke"] +fn tmux_width_resize_restore_keeps_visible_content_anchored() -> Result<()> { + if cfg!(windows) { + return Ok(()); + } + if Command::new("tmux").arg("-V").output().is_err() { + eprintln!("skipping resize smoke because tmux is unavailable"); + return Ok(()); + } + + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex = codex_binary(&repo_root)?; + let codex_home = tempdir()?; + let fixture_dir = tempdir()?; + let fixture = fixture_dir.path().join("resize-reflow.sse"); + write_fixture(&fixture)?; + write_config( + codex_home.path(), + &repo_root, + /*terminal_resize_reflow_enabled*/ true, + )?; + write_auth(codex_home.path())?; + + let session_name = format!("codex-resize-width-{}", std::process::id()); + let _session = TmuxSession { + name: session_name.clone(), + }; + + let prompt = "Send me a large paragraph of text for testing."; + let start_output = checked_output( + Command::new("tmux") + .arg("new-session") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-x") + .arg("120") + .arg("-y") + .arg("40") + .arg("-s") + .arg(&session_name) + .arg("--") + .arg("env") + .arg(format!("CODEX_HOME={}", codex_home.path().display())) + .arg("OPENAI_API_KEY=dummy") + .arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display())) + .arg(codex) + .arg("-c") + .arg("analytics.enabled=false") + .arg("--no-alt-screen") + .arg("-C") + .arg(&repo_root) + .arg(prompt), + )?; + let codex_pane = stdout_text(&start_output).trim().to_string(); + anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); + + wait_for_capture_contains( + &codex_pane, + "resize reflow sentinel", + Duration::from_secs(/*secs*/ 15), + )?; + wait_for_capture_contains( + &codex_pane, + "gpt-5.4 default", + Duration::from_secs(/*secs*/ 15), + )?; + let draft = "Notice where we are here in terms of y location."; + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(draft), + )?; + let baseline_capture = + wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; + let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; + let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") + .context("history row before split")?; + + let split_output = checked_output( + Command::new("tmux") + .arg("split-window") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-h") + .arg("-l") + .arg("40") + .arg("-t") + .arg(&codex_pane) + .arg("sleep") + .arg("30"), + )?; + let split_pane = stdout_text(&split_output).trim().to_string(); + + sleep(Duration::from_millis(/*millis*/ 750)); + check( + Command::new("tmux") + .arg("kill-pane") + .arg("-t") + .arg(&split_pane), + )?; + + sleep(Duration::from_millis(/*millis*/ 1_000)); + let restored_capture = capture_pane(&codex_pane)?; + let restored_row = + last_composer_row(&restored_capture).context("composer row after width restore")?; + let restored_history_row = first_row_containing(&restored_capture, "resize reflow sentinel") + .context("history row after width restore")?; + anyhow::ensure!( + restored_row == baseline_row, + "composer row drifted after width restore: baseline={baseline_row}, \ + restored={restored_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + anyhow::ensure!( + restored_history_row == baseline_history_row, + "history row drifted after width restore: baseline={baseline_history_row}, \ + restored={restored_history_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + + Ok(()) +} + +fn run_repeated_resize_smoke(terminal_resize_reflow_enabled: bool) -> Result<()> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let codex = codex_binary(&repo_root)?; + let codex_home = tempdir()?; + let fixture_dir = tempdir()?; + let fixture = fixture_dir.path().join("resize-reflow.sse"); + write_fixture(&fixture)?; + write_config( + codex_home.path(), + &repo_root, + terminal_resize_reflow_enabled, + )?; + write_auth(codex_home.path())?; + + let suffix = if terminal_resize_reflow_enabled { + "enabled" + } else { + "disabled" + }; + let session_name = format!("codex-resize-repeat-{suffix}-{}", std::process::id()); + let _session = TmuxSession { + name: session_name.clone(), + }; + + let prompt = "Send me a large paragraph of text for testing."; + let start_output = checked_output( + Command::new("tmux") + .arg("new-session") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-x") + .arg("120") + .arg("-y") + .arg("40") + .arg("-s") + .arg(&session_name) + .arg("--") + .arg("env") + .arg(format!("CODEX_HOME={}", codex_home.path().display())) + .arg("OPENAI_API_KEY=dummy") + .arg(format!("CODEX_RS_SSE_FIXTURE={}", fixture.display())) + .arg(codex) + .arg("-c") + .arg("analytics.enabled=false") + .arg("--no-alt-screen") + .arg("-C") + .arg(&repo_root) + .arg(prompt), + )?; + let codex_pane = stdout_text(&start_output).trim().to_string(); + anyhow::ensure!(!codex_pane.is_empty(), "tmux did not report a pane id"); + + wait_for_capture_contains( + &codex_pane, + "resize reflow sentinel", + Duration::from_secs(/*secs*/ 15), + )?; + wait_for_capture_contains( + &codex_pane, + "gpt-5.4 default", + Duration::from_secs(/*secs*/ 15), + )?; + let draft = "Notice where we are here in terms of y location."; + check( + Command::new("tmux") + .arg("send-keys") + .arg("-t") + .arg(&codex_pane) + .arg("-l") + .arg(draft), + )?; + let baseline_capture = + wait_for_capture_contains(&codex_pane, draft, Duration::from_secs(/*secs*/ 15))?; + let baseline_row = last_composer_row(&baseline_capture).context("composer row before split")?; + let baseline_history_row = first_row_containing(&baseline_capture, "resize reflow sentinel") + .context("history row before split")?; + + for cycle in 1..=3 { + let split_output = checked_output( + Command::new("tmux") + .arg("split-window") + .arg("-d") + .arg("-P") + .arg("-F") + .arg("#{pane_id}") + .arg("-v") + .arg("-l") + .arg("12") + .arg("-t") + .arg(&codex_pane) + .arg("sleep") + .arg("30"), + )?; + let split_pane = stdout_text(&split_output).trim().to_string(); + + sleep(Duration::from_millis(/*millis*/ 250)); + check( + Command::new("tmux") + .arg("kill-pane") + .arg("-t") + .arg(&split_pane), + )?; + + sleep(Duration::from_millis(/*millis*/ 500)); + let restored_capture = capture_pane(&codex_pane)?; + let restored_row = last_composer_row(&restored_capture) + .with_context(|| format!("composer row after resize cycle {cycle}"))?; + let restored_history_row = + first_row_containing(&restored_capture, "resize reflow sentinel") + .with_context(|| format!("history row after resize cycle {cycle}"))?; + if terminal_resize_reflow_enabled { + anyhow::ensure!( + restored_row == baseline_row, + "composer row drifted after resize cycle {cycle} with terminal_resize_reflow={terminal_resize_reflow_enabled}: \ + baseline={baseline_row}, restored={restored_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + anyhow::ensure!( + restored_history_row == baseline_history_row, + "history row drifted after resize cycle {cycle} with terminal_resize_reflow={terminal_resize_reflow_enabled}: \ + baseline={baseline_history_row}, restored={restored_history_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + } else { + anyhow::ensure!( + restored_row <= baseline_row + 1, + "composer row snapped downward after resize cycle {cycle} with terminal_resize_reflow={terminal_resize_reflow_enabled}: \ + baseline={baseline_row}, restored={restored_row}\n\ + baseline:\n{baseline_capture}\n\ + restored:\n{restored_capture}" + ); + } + } + + Ok(()) +} + +struct TmuxSession { + name: String, +} + +impl Drop for TmuxSession { + fn drop(&mut self) { + let _ = Command::new("tmux") + .arg("kill-session") + .arg("-t") + .arg(&self.name) + .output(); + } +} + +fn codex_binary(repo_root: &Path) -> Result { + if let Ok(path) = codex_utils_cargo_bin::cargo_bin("codex") { + return Ok(path); + } + + let fallback = repo_root.join("codex-rs/target/debug/codex"); + anyhow::ensure!( + fallback.is_file(), + "codex binary is unavailable; run `cargo build -p codex-cli` first" + ); + Ok(fallback) +} + +fn write_config( + codex_home: &Path, + repo_root: &Path, + terminal_resize_reflow_enabled: bool, +) -> Result<()> { + let repo_root_display = repo_root.display(); + let config = format!( + r#"model = "gpt-5.4" +model_provider = "openai" +suppress_unstable_features_warning = true + +[features] +terminal_resize_reflow = {terminal_resize_reflow_enabled} + +[projects."{repo_root_display}"] +trust_level = "trusted" +"# + ); + std::fs::write(codex_home.join("config.toml"), config)?; + Ok(()) +} + +fn write_auth(codex_home: &Path) -> Result<()> { + std::fs::write( + codex_home.join("auth.json"), + r#"{"OPENAI_API_KEY":"dummy","tokens":null,"last_refresh":null}"#, + )?; + Ok(()) +} + +fn write_fixture(path: &Path) -> Result<()> { + let text = "resize reflow sentinel says hi. This paragraph is intentionally long enough to exercise terminal wrapping, scrollback redraw, and pane resize behavior without requiring a live model response. It includes enough ordinary prose to wrap across several rows in a narrow tmux pane, then keep going so repeated split and restore cycles have visible history above the composer. If a resize path accidentally inserts blank rows or anchors the viewport lower on each pass, the composer row will drift after the pane returns to its original height."; + let created = serde_json::json!({ + "type": "response.created", + "response": { "id": "resp-resize-smoke" }, + }); + let done = serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "content": [ + { "type": "output_text", "text": text } + ], + }, + }); + let completed = serde_json::json!({ + "type": "response.completed", + "response": { "id": "resp-resize-smoke", "output": [] }, + }); + let fixture = format!( + "event: response.created\ndata: {created}\n\n\ + event: response.output_item.done\ndata: {done}\n\n\ + event: response.completed\ndata: {completed}\n\n" + ); + std::fs::write(path, fixture)?; + Ok(()) +} + +fn wait_for_capture_contains(pane: &str, needle: &str, timeout: Duration) -> Result { + let deadline = Instant::now() + timeout; + let mut last_capture = String::new(); + while Instant::now() < deadline { + last_capture = capture_pane(pane)?; + if last_capture.contains(needle) { + return Ok(last_capture); + } + sleep(Duration::from_millis(/*millis*/ 100)); + } + + anyhow::bail!("timed out waiting for {needle:?}; last capture:\n{last_capture}"); +} + +fn capture_pane(pane: &str) -> Result { + let output = output( + Command::new("tmux") + .arg("capture-pane") + .arg("-p") + .arg("-t") + .arg(pane), + )?; + Ok(String::from_utf8_lossy(&output.stdout).to_string()) +} + +fn last_composer_row(capture: &str) -> Option { + capture + .lines() + .enumerate() + .filter_map(|(index, line)| { + if line.trim_start().starts_with('\u{203a}') { + Some(index) + } else { + None + } + }) + .last() +} + +fn first_row_containing(capture: &str, needle: &str) -> Option { + capture + .lines() + .enumerate() + .find_map(|(index, line)| line.contains(needle).then_some(index)) +} + +fn check(command: &mut Command) -> Result<()> { + checked_output(command)?; + Ok(()) +} + +fn checked_output(command: &mut Command) -> Result { + let output = output(command)?; + anyhow::ensure!( + output.status.success(), + "command failed with status {:?}\nstdout:\n{}\nstderr:\n{}", + output.status.code(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + Ok(output) +} + +fn output(command: &mut Command) -> Result { + command + .output() + .with_context(|| format!("failed to run {command:?}")) +} + +fn stdout_text(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).to_string() +}