From 5591912f0bf176257f71b3efbd37ee4479dfdfaf Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Sat, 25 Apr 2026 22:00:32 -0300 Subject: [PATCH] fix(tui): reflow scrollback on terminal resize (#18575) Fixes multiple scrollback and terminal resize issues: #5538, #5576, #8352, #12223, #16165, and #15380. ## Why Codex writes finalized transcript output into terminal scrollback after wrapping it for the current viewport width. A later terminal resize could leave that scrollback shaped for the old width, so wider windows kept narrow output and narrower windows could show stale wrapping artifacts until enough new output replaced the visible area. This is also the foundation PR for responsive markdown tables. Table rendering needs finalized transcript content to be width-sensitive after insertion, not only while content is first streaming. Markdown table rendering itself stays in #18576. ## Stack - PR1: resize backlog reflow and interrupt cleanup - #18576: markdown table support ## What Changed - Rebuild source-backed transcript history when the terminal width changes. `terminal_resize_reflow` is introduced through the experimental feature system, but is enabled by default for this rollout so we can validate behavior across real terminals. - Preserve assistant and plan stream source so finalized streaming output can participate in resize reflow after consolidation. - Debounce resize work, but force a final source-backed reflow when a resize happened during active or unconsolidated streaming output. - Clear stale pending history lines on resize so old-width wrapped output is not emitted just before rebuilt scrollback. - Bound replay work with `[tui.terminal_resize_reflow].max_rows`: omitted uses terminal-specific defaults, `0` keeps all rendered rows, and a positive value sets an explicit cap. The cap applies both while initially replaying a resumed transcript into scrollback and when rebuilding scrollback after terminal resize. - Consolidate interrupted assistant streams before cleanup, then clear pending stream output and active-tail state consistently. - Move resize reflow and thread event buffering helpers out of `app.rs` into dedicated TUI modules. - Add focused coverage for resize reflow, feature-gated behavior, streaming source preservation, interrupted output cleanup, unicode-neutral text, terminal-specific row caps, and composer/layout stability. ## Runtime Bounds Resize reflow keeps only the most recent rendered rows when a row cap is active. The default is `auto`, which maps to the detected terminal's default scrollback size where Codex can identify it: VS Code `1000`, Windows Terminal `9001`, WezTerm `3500`, and Alacritty `10000`. Terminals without a dedicated mapping use the conservative fallback of `1000` rows. Users can override this with `[tui.terminal_resize_reflow] max_rows = N`, or set `max_rows = 0` to disable row limiting. ## Validation - `just fmt` - `git diff --check` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui reflow` - `cargo test --manifest-path codex-rs/Cargo.toml -p codex-tui transcript_reflow` - `just fix -p codex-tui` - PR CI in progress on the squashed branch --- codex-rs/config/src/types.rs | 10 + codex-rs/core/config.schema.json | 13 + codex-rs/core/src/config/config_tests.rs | 81 +++ codex-rs/core/src/config/mod.rs | 35 + codex-rs/features/src/lib.rs | 12 + codex-rs/features/src/tests.rs | 16 +- codex-rs/tui/src/app.rs | 36 +- codex-rs/tui/src/app/config_persistence.rs | 22 + codex-rs/tui/src/app/event_dispatch.rs | 93 ++- codex-rs/tui/src/app/history_ui.rs | 6 + codex-rs/tui/src/app/resize_reflow.rs | 482 +++++++++++++ codex-rs/tui/src/app/session_lifecycle.rs | 7 +- codex-rs/tui/src/app/test_support.rs | 2 + codex-rs/tui/src/app/tests.rs | 147 ++++ codex-rs/tui/src/app/thread_routing.rs | 11 + codex-rs/tui/src/app_backtrack.rs | 2 +- codex-rs/tui/src/app_event.rs | 26 + codex-rs/tui/src/chatwidget.rs | 111 ++- codex-rs/tui/src/custom_terminal.rs | 8 +- codex-rs/tui/src/cwd_prompt.rs | 2 +- .../src/external_agent_config_migration.rs | 2 +- codex-rs/tui/src/history_cell.rs | 287 +++++++- codex-rs/tui/src/insert_history.rs | 153 ++-- codex-rs/tui/src/lib.rs | 3 + codex-rs/tui/src/markdown_stream.rs | 170 ++++- codex-rs/tui/src/model_migration.rs | 2 +- .../tui/src/onboarding/onboarding_screen.rs | 2 +- codex-rs/tui/src/pager_overlay.rs | 101 ++- codex-rs/tui/src/render/line_utils.rs | 1 + codex-rs/tui/src/resize_reflow_cap.rs | 183 +++++ codex-rs/tui/src/resume_picker.rs | 2 +- codex-rs/tui/src/streaming/controller.rs | 661 +++++++++++------- codex-rs/tui/src/streaming/mod.rs | 9 +- codex-rs/tui/src/transcript_reflow.rs | 302 ++++++++ codex-rs/tui/src/tui.rs | 116 ++- codex-rs/tui/src/tui/event_stream.rs | 13 +- codex-rs/tui/src/update_prompt.rs | 2 +- codex-rs/tui/src/width.rs | 72 ++ codex-rs/tui/tests/suite/mod.rs | 1 + codex-rs/tui/tests/suite/resize_reflow.rs | 613 ++++++++++++++++ 40 files changed, 3427 insertions(+), 390 deletions(-) create mode 100644 codex-rs/tui/src/app/resize_reflow.rs create mode 100644 codex-rs/tui/src/resize_reflow_cap.rs create mode 100644 codex-rs/tui/src/transcript_reflow.rs create mode 100644 codex-rs/tui/src/width.rs create mode 100644 codex-rs/tui/tests/suite/resize_reflow.rs 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() +}