diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 305082ef13..d2c2cfdba7 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -651,11 +651,59 @@ jobs: prefix="${NPM_TAG}-" fi + root_tarball="dist/npm/codex-npm-${VERSION}.tgz" + sdk_tarball="dist/npm/codex-sdk-npm-${VERSION}.tgz" + # Keep this list in sync with CODEX_PLATFORM_PACKAGES in + # codex-cli/scripts/build_npm_package.py. The root wrapper advances + # @openai/codex@latest as soon as it publishes, so every platform + # package it aliases must already exist in the registry first. + platform_tarballs=( + "dist/npm/codex-npm-linux-x64-${VERSION}.tgz" + "dist/npm/codex-npm-linux-arm64-${VERSION}.tgz" + "dist/npm/codex-npm-darwin-x64-${VERSION}.tgz" + "dist/npm/codex-npm-darwin-arm64-${VERSION}.tgz" + "dist/npm/codex-npm-win32-x64-${VERSION}.tgz" + "dist/npm/codex-npm-win32-arm64-${VERSION}.tgz" + ) + + for required_tarball in "${platform_tarballs[@]}" "${root_tarball}"; do + if [[ ! -f "${required_tarball}" ]]; then + echo "Missing npm tarball: ${required_tarball}" + exit 1 + fi + done + shopt -s nullglob - tarballs=(dist/npm/*-"${VERSION}".tgz) - if [[ ${#tarballs[@]} -eq 0 ]]; then - echo "No npm tarballs found in dist/npm for version ${VERSION}" - exit 1 + other_tarballs=() + for tarball in dist/npm/*-"${VERSION}".tgz; do + if [[ "${tarball}" == "${root_tarball}" || "${tarball}" == "${sdk_tarball}" ]]; then + continue + fi + + is_platform_tarball=false + for platform_tarball in "${platform_tarballs[@]}"; do + if [[ "${tarball}" == "${platform_tarball}" ]]; then + is_platform_tarball=true + break + fi + done + if [[ "${is_platform_tarball}" == true ]]; then + continue + fi + + other_tarballs+=("${tarball}") + done + + # Publish the platform packages before the root CLI wrapper. The root + # wrapper advances @openai/codex@latest, so it should only publish + # after the optional dependency versions it references exist. + tarballs=( + "${platform_tarballs[@]}" + "${other_tarballs[@]}" + "${root_tarball}" + ) + if [[ -f "${sdk_tarball}" ]]; then + tarballs+=("${sdk_tarball}") fi for tarball in "${tarballs[@]}"; do diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 69e8f66b52..5ff3f462f1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2099,7 +2099,6 @@ dependencies = [ "assert_matches", "clap", "clap_complete", - "codex-api", "codex-app-server", "codex-app-server-protocol", "codex-app-server-test-client", @@ -2116,7 +2115,6 @@ dependencies = [ "codex-login", "codex-mcp", "codex-mcp-server", - "codex-model-provider", "codex-models-manager", "codex-protocol", "codex-responses-api-proxy", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 2a9c5a6ff7..5f4f3aee10 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -24,7 +24,6 @@ codex-app-server = { workspace = true } codex-app-server-protocol = { workspace = true } codex-app-server-test-client = { workspace = true } codex-arg0 = { workspace = true } -codex-api = { workspace = true } codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } @@ -39,7 +38,6 @@ codex-login = { workspace = true } codex-mcp = { workspace = true } codex-mcp-server = { workspace = true } codex-models-manager = { workspace = true } -codex-model-provider = { workspace = true } codex-protocol = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-rmcp-client = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 2481ecd6fe..35005f5be3 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -43,14 +43,11 @@ mod app_cmd; mod desktop_app; mod marketplace_cmd; mod mcp_cmd; -mod responses_cmd; #[cfg(not(windows))] mod wsl_paths; use crate::marketplace_cmd::MarketplaceCli; use crate::mcp_cmd::McpCli; -use crate::responses_cmd::ResponsesCommand; -use crate::responses_cmd::run_responses_command; use codex_core::build_models_manager; use codex_core::clear_memory_roots_contents; @@ -163,10 +160,6 @@ enum Subcommand { #[clap(hide = true)] ResponsesApiProxy(ResponsesApiProxyArgs), - /// Internal: send one raw Responses API payload through Codex auth. - #[clap(hide = true)] - Responses(ResponsesCommand), - /// Internal: relay stdio to a Unix domain socket. #[clap(hide = true, name = "stdio-to-uds")] StdioToUds(StdioToUdsCommand), @@ -1130,14 +1123,6 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> { tokio::task::spawn_blocking(move || codex_responses_api_proxy::run_main(args)) .await??; } - Some(Subcommand::Responses(ResponsesCommand {})) => { - reject_remote_mode_for_subcommand( - root_remote.as_deref(), - root_remote_auth_token_env.as_deref(), - "responses", - )?; - run_responses_command(root_config_overrides).await?; - } Some(Subcommand::StdioToUds(cmd)) => { reject_remote_mode_for_subcommand( root_remote.as_deref(), @@ -1837,12 +1822,13 @@ mod tests { } #[test] - fn responses_subcommand_is_hidden_from_help_but_parses() { - let help = MultitoolCli::command().render_help().to_string(); - assert!(!help.contains("responses")); - - let cli = MultitoolCli::try_parse_from(["codex", "responses"]).expect("parse"); - assert!(matches!(cli.subcommand, Some(Subcommand::Responses(_)))); + fn responses_subcommand_is_not_registered() { + let command = MultitoolCli::command(); + assert!( + command + .get_subcommands() + .all(|subcommand| subcommand.get_name() != "responses") + ); } fn help_from_args(args: &[&str]) -> String { diff --git a/codex-rs/cli/src/responses_cmd.rs b/codex-rs/cli/src/responses_cmd.rs deleted file mode 100644 index 6974198ef7..0000000000 --- a/codex-rs/cli/src/responses_cmd.rs +++ /dev/null @@ -1,246 +0,0 @@ -use clap::Parser; -use codex_core::config::Config; -use codex_model_provider::create_model_provider; -use codex_utils_cli::CliConfigOverrides; -use serde_json::json; -use tokio::io::AsyncReadExt; - -#[derive(Debug, Parser)] -pub(crate) struct ResponsesCommand {} - -pub(crate) async fn run_responses_command( - root_config_overrides: CliConfigOverrides, -) -> anyhow::Result<()> { - let mut payload_text = String::new(); - tokio::io::stdin().read_to_string(&mut payload_text).await?; - if payload_text.trim().is_empty() { - anyhow::bail!("expected Responses API JSON payload on stdin"); - } - - let payload: serde_json::Value = serde_json::from_str(&payload_text) - .map_err(|err| anyhow::anyhow!("failed to parse Responses API JSON payload: {err}"))?; - if payload.get("stream").and_then(serde_json::Value::as_bool) != Some(true) { - anyhow::bail!("codex responses expects a streaming payload with `\"stream\": true`"); - } - - let cli_overrides = root_config_overrides - .parse_overrides() - .map_err(anyhow::Error::msg)?; - let config = Config::load_with_cli_overrides(cli_overrides).await?; - let base_auth_manager = codex_login::AuthManager::shared_from_config( - &config, /*enable_codex_api_key_env*/ true, - ); - let model_provider = create_model_provider(config.model_provider, Some(base_auth_manager)); - let api_provider = model_provider.api_provider().await?; - let api_auth = model_provider.api_auth().await?; - let client = codex_api::ResponsesClient::new( - codex_api::ReqwestTransport::new(codex_login::default_client::build_reqwest_client()), - api_provider, - api_auth, - ); - - let mut stream = client - .stream( - payload, - Default::default(), - codex_api::Compression::None, - /*turn_state*/ None, - ) - .await?; - while let Some(event) = stream.rx_event.recv().await { - let event = event?; - println!("{}", serde_json::to_string(&response_event_to_json(event))?); - } - - Ok(()) -} - -fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value { - match event { - codex_api::ResponseEvent::Created => { - json!({ "type": "response.created", "response": {} }) - } - codex_api::ResponseEvent::OutputItemDone(item) => { - json!({ "type": "response.output_item.done", "item": item }) - } - codex_api::ResponseEvent::OutputItemAdded(item) => { - json!({ "type": "response.output_item.added", "item": item }) - } - codex_api::ResponseEvent::ServerModel(model) => { - json!({ "type": "response.server_model", "model": model }) - } - codex_api::ResponseEvent::ModelVerifications(verifications) => { - json!({ "type": "response.model_verifications", "verifications": verifications }) - } - codex_api::ResponseEvent::ServerReasoningIncluded(included) => { - json!({ "type": "response.server_reasoning_included", "included": included }) - } - codex_api::ResponseEvent::Completed { - response_id, - token_usage, - } => { - let response = match token_usage { - Some(token_usage) => json!({ - "id": response_id, - "usage": { - "input_tokens": token_usage.input_tokens, - "input_tokens_details": { - "cached_tokens": token_usage.cached_input_tokens, - }, - "output_tokens": token_usage.output_tokens, - "output_tokens_details": { - "reasoning_tokens": token_usage.reasoning_output_tokens, - }, - "total_tokens": token_usage.total_tokens, - }, - }), - None => json!({ "id": response_id }), - }; - json!({ "type": "response.completed", "response": response }) - } - codex_api::ResponseEvent::OutputTextDelta(delta) => { - json!({ "type": "response.output_text.delta", "delta": delta }) - } - codex_api::ResponseEvent::ToolCallInputDelta { - item_id, - call_id, - delta, - } => { - json!({ - "type": "response.tool_call_input.delta", - "item_id": item_id, - "call_id": call_id, - "delta": delta, - }) - } - codex_api::ResponseEvent::ReasoningSummaryDelta { - delta, - summary_index, - } => json!({ - "type": "response.reasoning_summary_text.delta", - "delta": delta, - "summary_index": summary_index, - }), - codex_api::ResponseEvent::ReasoningContentDelta { - delta, - content_index, - } => json!({ - "type": "response.reasoning_text.delta", - "delta": delta, - "content_index": content_index, - }), - codex_api::ResponseEvent::ReasoningSummaryPartAdded { summary_index } => { - json!({ - "type": "response.reasoning_summary_part.added", - "summary_index": summary_index, - }) - } - codex_api::ResponseEvent::RateLimits(rate_limits) => { - json!({ "type": "response.rate_limits", "rate_limits": rate_limits }) - } - codex_api::ResponseEvent::ModelsEtag(etag) => { - json!({ "type": "response.models_etag", "etag": etag }) - } - } -} - -#[cfg(test)] -mod tests { - use super::response_event_to_json; - use codex_protocol::protocol::TokenUsage; - use pretty_assertions::assert_eq; - use serde_json::json; - - #[test] - fn response_events_keep_replayable_response_envelopes() { - let created = response_event_to_json(codex_api::ResponseEvent::Created); - assert_eq!(created, json!({"type": "response.created", "response": {}})); - - let completed = response_event_to_json(codex_api::ResponseEvent::Completed { - response_id: "resp-1".to_string(), - token_usage: Some(TokenUsage { - input_tokens: 10, - cached_input_tokens: 4, - output_tokens: 7, - reasoning_output_tokens: 3, - total_tokens: 17, - }), - }); - assert_eq!( - completed, - json!({ - "type": "response.completed", - "response": { - "id": "resp-1", - "usage": { - "input_tokens": 10, - "input_tokens_details": { - "cached_tokens": 4, - }, - "output_tokens": 7, - "output_tokens_details": { - "reasoning_tokens": 3, - }, - "total_tokens": 17, - }, - }, - }) - ); - - let completed_without_usage = response_event_to_json(codex_api::ResponseEvent::Completed { - response_id: "resp-2".to_string(), - token_usage: None, - }); - assert_eq!( - completed_without_usage, - json!({"type": "response.completed", "response": {"id": "resp-2"}}) - ); - } - - #[test] - fn reasoning_deltas_use_responses_event_names() { - let summary = response_event_to_json(codex_api::ResponseEvent::ReasoningSummaryDelta { - delta: "plan".to_string(), - summary_index: 1, - }); - assert_eq!( - summary, - json!({ - "type": "response.reasoning_summary_text.delta", - "delta": "plan", - "summary_index": 1, - }) - ); - - let content = response_event_to_json(codex_api::ResponseEvent::ReasoningContentDelta { - delta: "detail".to_string(), - content_index: 2, - }); - assert_eq!( - content, - json!({ - "type": "response.reasoning_text.delta", - "delta": "detail", - "content_index": 2, - }) - ); - } - - #[test] - fn tool_call_input_delta_uses_responses_event_name() { - let delta = response_event_to_json(codex_api::ResponseEvent::ToolCallInputDelta { - item_id: "item-1".to_string(), - call_id: Some("call-1".to_string()), - delta: "patch".to_string(), - }); - assert_eq!( - delta, - json!({ - "type": "response.tool_call_input.delta", - "item_id": "item-1", - "call_id": "call-1", - "delta": "patch", - }) - ); - } -} diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 6f118d1030..4b150b55f1 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -81,6 +81,9 @@ pub enum ResponseEvent { Completed { response_id: String, token_usage: Option, + /// Did the model affirmatively end its turn? Some providers do not set this, + /// so we rely on fallback logic when this is `None`. + end_turn: Option, }, OutputTextDelta(String), ToolCallInputDelta { diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index 7b4a4ceab0..fb1742463f 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -123,6 +123,8 @@ struct ResponseCompleted { id: String, #[serde(default)] usage: Option, + #[serde(default)] + end_turn: Option, } #[derive(Debug, Deserialize)] @@ -382,6 +384,7 @@ pub fn process_responses_event( return Ok(Some(ResponseEvent::Completed { response_id: resp.id, token_usage: resp.usage.map(Into::into), + end_turn: resp.end_turn, })); } Err(err) => { @@ -704,9 +707,11 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected third event: {other:?}"), } @@ -843,9 +848,11 @@ mod tests { Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected event: {other:?}"), } @@ -1148,7 +1155,8 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } @@ -1184,7 +1192,8 @@ mod tests { &events[2], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } @@ -1218,7 +1227,8 @@ mod tests { &events[1], ResponseEvent::Completed { response_id, - token_usage: None + token_usage: None, + end_turn: None, } if response_id == "resp-1" ); } diff --git a/codex-rs/codex-api/tests/sse_end_to_end.rs b/codex-rs/codex-api/tests/sse_end_to_end.rs index 107c101724..bf880fefcf 100644 --- a/codex-rs/codex-api/tests/sse_end_to_end.rs +++ b/codex-rs/codex-api/tests/sse_end_to_end.rs @@ -158,9 +158,11 @@ async fn responses_stream_parses_items_and_completed_end_to_end() -> Result<()> ResponseEvent::Completed { response_id, token_usage, + end_turn, } => { assert_eq!(response_id, "resp1"); assert!(token_usage.is_none()); + assert!(end_turn.is_none()); } other => panic!("unexpected third event: {other:?}"), } diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 7413686a77..6668e25318 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 dbc2316908..3fbbfaf6eb 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/client.rs b/codex-rs/core/src/client.rs index cb63ca4551..c49e28f20a 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1655,6 +1655,7 @@ where Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, }) => { if let Some(usage) = &token_usage { session_telemetry.sse_event_completed( @@ -1680,6 +1681,7 @@ where .send(Ok(ResponseEvent::Completed { response_id, token_usage, + end_turn, })) .await .is_err() diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8e51c040ba..3da4f08ac6 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -522,10 +522,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( @@ -1544,10 +1562,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#" @@ -5464,6 +5541,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(), @@ -5657,6 +5735,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(), @@ -5804,6 +5883,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(), @@ -5936,6 +6016,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 2dfe2eec0e..be3db47e4f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -466,6 +466,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. @@ -722,6 +725,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() @@ -1597,6 +1616,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, @@ -2045,6 +2078,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) @@ -2617,6 +2651,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/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index b3786eadc6..6383b892ec 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -2133,6 +2133,7 @@ async fn try_run_sampling_request( ResponseEvent::Completed { response_id: _, token_usage, + end_turn, } => { flush_assistant_text_segments_all( &sess, @@ -2144,7 +2145,9 @@ async fn try_run_sampling_request( sess.update_token_usage_info(&turn_context, token_usage.as_ref()) .await; should_emit_turn_diff = true; - + if let Some(false) = end_turn { + needs_follow_up = true; + } break Ok(SamplingRequestResult { needs_follow_up, last_agent_message, diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index 38c209df4a..6a2a2bc717 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 8249198e3b..e410159b7f 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 dbf0cc5daa..77c1f52775 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 da14270154..44ef5f664d 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -669,6 +669,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 71292ab933..7e096c6b92 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 703cc38c3d..f6fec98f13 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 0000000000..b2702f470f --- /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 dddae35e08..c51863bd16 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 4dc724ee5e..29b7dede05 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 63f7be8612..da83a72c40 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; @@ -3633,6 +3635,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)), @@ -3690,6 +3694,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)), @@ -3745,6 +3751,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 bf1f95555a..5f0f52c2c7 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 cc99a791df..da1f82e626 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 fa3549e6a1..7df90020e0 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 ffa631d1f1..1fbe122b6e 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 { @@ -2622,7 +2635,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, )); } @@ -2652,18 +2665,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; @@ -2781,10 +2801,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 { @@ -5038,7 +5063,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, )); } @@ -11359,6 +11384,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() } @@ -11485,6 +11556,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); } @@ -11503,6 +11575,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( @@ -11634,6 +11720,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 556992b805..cadf1fa13f 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 0dace9c7b6..264fa39c79 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 ecc2f75b4b..0e709f9457 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 b6806f88f6..16c2440de4 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 76cd699e86..4f3ea981bd 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 2dbe067077..7f65e3b049 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -142,11 +142,14 @@ mod model_catalog; mod model_migration; mod multi_agents; mod notifications; +#[cfg(any(not(debug_assertions), test))] +mod npm_registry; pub(crate) mod onboarding; 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; @@ -162,15 +165,19 @@ mod terminal_title; mod text_formatting; mod theme_picker; mod tooltips; +mod transcript_reflow; mod tui; mod ui_consts; pub(crate) mod update_action; pub use update_action::UpdateAction; mod update_prompt; +#[cfg(any(not(debug_assertions), test))] +mod update_versions; 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 3eb37e345e..311ea202c4 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 1b2de5ecfd..c307abb78f 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/npm_registry.rs b/codex-rs/tui/src/npm_registry.rs new file mode 100644 index 0000000000..61ff2425e6 --- /dev/null +++ b/codex-rs/tui/src/npm_registry.rs @@ -0,0 +1,130 @@ +use serde::Deserialize; +use std::collections::HashMap; + +#[cfg(not(debug_assertions))] +pub(crate) const PACKAGE_URL: &str = "https://registry.npmjs.org/@openai%2fcodex"; + +#[derive(Deserialize, Debug, Clone)] +pub(crate) struct NpmPackageInfo { + #[serde(rename = "dist-tags")] + dist_tags: HashMap, + versions: HashMap, +} + +#[derive(Deserialize, Debug, Clone)] +struct NpmPackageVersionInfo { + dist: Option, +} + +#[derive(Deserialize, Debug, Clone)] +struct NpmPackageDist { + tarball: Option, + integrity: Option, +} + +pub(crate) fn ensure_version_ready( + package_info: &NpmPackageInfo, + version: &str, +) -> anyhow::Result<()> { + let version = version.trim(); + + match package_info.dist_tags.get("latest").map(String::as_str) { + Some(latest) if latest == version => {} + Some(latest) => anyhow::bail!( + "npm latest dist-tag points to {latest}, expected GitHub release {version}" + ), + None => anyhow::bail!("npm package is missing latest dist-tag"), + } + + version_info_with_dist(package_info, version)?; + Ok(()) +} + +fn version_info_with_dist<'a>( + package_info: &'a NpmPackageInfo, + version: &str, +) -> anyhow::Result<&'a NpmPackageVersionInfo> { + let info = package_info + .versions + .get(version) + .ok_or_else(|| anyhow::anyhow!("npm package version {version} is missing"))?; + let Some(dist) = info.dist.as_ref() else { + anyhow::bail!("npm package version {version} is missing dist metadata"); + }; + let has_tarball = dist + .tarball + .as_deref() + .is_some_and(|tarball| !tarball.is_empty()); + if !has_tarball { + anyhow::bail!("npm package version {version} is missing dist.tarball"); + } + let has_integrity = dist + .integrity + .as_ref() + .is_some_and(|integrity| !integrity.is_empty()); + if !has_integrity { + anyhow::bail!("npm package version {version} is missing dist.integrity"); + } + Ok(info) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn version_json(version: &str) -> serde_json::Value { + serde_json::json!({ + "dist": { + "integrity": format!("sha512-{version}"), + "tarball": format!("https://registry.npmjs.org/@openai/codex/-/codex-{version}.tgz"), + } + }) + } + + fn package_info(github_latest: &str, npm_latest: &str) -> NpmPackageInfo { + let mut versions = serde_json::Map::new(); + versions.insert(github_latest.to_string(), version_json(github_latest)); + + serde_json::from_value(serde_json::json!({ + "dist-tags": { "latest": npm_latest }, + "versions": serde_json::Value::Object(versions), + })) + .expect("valid npm package metadata") + } + + #[test] + fn ready_version_requires_latest_dist_tag_and_root_dist() { + let latest = "1.2.3"; + let package_info = package_info(latest, latest); + + ensure_version_ready(&package_info, latest).expect("npm package is ready"); + } + + #[test] + fn ready_version_rejects_stale_latest_dist_tag() { + let package_info = package_info("1.2.3", "1.2.2"); + + let err = ensure_version_ready(&package_info, "1.2.3") + .expect_err("npm latest dist-tag must match GitHub latest"); + assert!( + err.to_string().contains("latest dist-tag"), + "error should name stale latest dist-tag: {err}" + ); + } + + #[test] + fn ready_version_rejects_missing_root_dist() { + let package_info: NpmPackageInfo = serde_json::from_value(serde_json::json!({ + "dist-tags": { "latest": "1.2.3" }, + "versions": { "1.2.3": {} }, + })) + .expect("valid npm package metadata"); + + let err = ensure_version_ready(&package_info, "1.2.3") + .expect_err("root package must have dist metadata"); + assert!( + err.to_string().contains("missing dist metadata"), + "error should name missing dist metadata: {err}" + ); + } +} diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index 0c7ebda080..0a6e8a3d7a 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 bca5f1f360..9fe0e3916e 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 175b79b2a8..54970f4486 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 0000000000..4dd9ffb199 --- /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 fe0825ccd8..43fa6c9486 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 d524e707a6..2def4ae8bb 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 ae3b68742a..ddbac2e4c5 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 0000000000..33318a4d0f --- /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 291a8ca63c..79e4cf7d25 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 2ce0aa7d2c..dcc6e17e0e 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 ab9c93f424..4d5a9e1287 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/update_versions.rs b/codex-rs/tui/src/update_versions.rs new file mode 100644 index 0000000000..29ff04369b --- /dev/null +++ b/codex-rs/tui/src/update_versions.rs @@ -0,0 +1,70 @@ +pub(crate) fn is_newer(latest: &str, current: &str) -> Option { + match (parse_version(latest), parse_version(current)) { + (Some(l), Some(c)) => Some(l > c), + _ => None, + } +} + +pub(crate) fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result { + latest_tag_name + .strip_prefix("rust-v") + .map(str::to_owned) + .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'")) +} + +pub(crate) fn is_source_build_version(version: &str) -> bool { + parse_version(version) == Some((0, 0, 0)) +} + +fn parse_version(v: &str) -> Option<(u64, u64, u64)> { + let mut iter = v.trim().split('.'); + let maj = iter.next()?.parse::().ok()?; + let min = iter.next()?.parse::().ok()?; + let pat = iter.next()?.parse::().ok()?; + Some((maj, min, pat)) +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn extracts_version_from_latest_tag() { + assert_eq!( + extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"), + "1.5.0" + ); + } + + #[test] + fn latest_tag_without_prefix_is_invalid() { + assert!(extract_version_from_latest_tag("v1.5.0").is_err()); + } + + #[test] + fn prerelease_version_is_not_considered_newer() { + assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); + assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None); + } + + #[test] + fn plain_semver_comparisons_work() { + assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true)); + assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false)); + assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true)); + assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false)); + } + + #[test] + fn source_build_version_is_not_checked() { + assert!(is_source_build_version("0.0.0")); + assert!(!is_source_build_version("0.1.0")); + } + + #[test] + fn whitespace_is_ignored() { + assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); + assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); + } +} diff --git a/codex-rs/tui/src/updates.rs b/codex-rs/tui/src/updates.rs index 0acb30b342..e99852ead9 100644 --- a/codex-rs/tui/src/updates.rs +++ b/codex-rs/tui/src/updates.rs @@ -1,8 +1,13 @@ #![cfg(not(debug_assertions))] use crate::legacy_core::config::Config; +use crate::npm_registry; +use crate::npm_registry::NpmPackageInfo; use crate::update_action; use crate::update_action::UpdateAction; +use crate::update_versions::extract_version_from_latest_tag; +use crate::update_versions::is_newer; +use crate::update_versions::is_source_build_version; use chrono::DateTime; use chrono::Duration; use chrono::Utc; @@ -19,6 +24,7 @@ pub fn get_upgrade_version(config: &Config) -> Option { return None; } + let action = update_action::get_update_action(); let version_file = version_filepath(config); let info = read_version_info(&version_file).ok(); @@ -30,7 +36,7 @@ pub fn get_upgrade_version(config: &Config) -> Option { // isn’t blocked by a network call. The UI reads the previously cached // value (if any) for this run; the next run shows the banner if needed. tokio::spawn(async move { - check_for_update(&version_file) + check_for_update(&version_file, action) .await .inspect_err(|e| tracing::error!("Failed to update version: {e}")) }); @@ -78,8 +84,8 @@ fn read_version_info(version_file: &Path) -> anyhow::Result { Ok(serde_json::from_str(&contents)?) } -async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { - let latest_version = match update_action::get_update_action() { +async fn check_for_update(version_file: &Path, action: Option) -> anyhow::Result<()> { + let latest_version = match action { Some(UpdateAction::BrewUpgrade) => { let HomebrewCaskInfo { version } = create_client() .get(HOMEBREW_CASK_API_URL) @@ -90,17 +96,20 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { .await?; version } - _ => { - let ReleaseInfo { - tag_name: latest_tag_name, - } = create_client() - .get(LATEST_RELEASE_URL) + Some(UpdateAction::NpmGlobalLatest) | Some(UpdateAction::BunGlobalLatest) => { + let latest_version = fetch_latest_github_release_version().await?; + let package_info = create_client() + .get(npm_registry::PACKAGE_URL) .send() .await? .error_for_status()? - .json::() + .json::() .await?; - extract_version_from_latest_tag(&latest_tag_name)? + npm_registry::ensure_version_ready(&package_info, &latest_version)?; + latest_version + } + Some(UpdateAction::StandaloneUnix) | Some(UpdateAction::StandaloneWindows) | None => { + fetch_latest_github_release_version().await? } }; @@ -120,18 +129,17 @@ async fn check_for_update(version_file: &Path) -> anyhow::Result<()> { Ok(()) } -fn is_newer(latest: &str, current: &str) -> Option { - match (parse_version(latest), parse_version(current)) { - (Some(l), Some(c)) => Some(l > c), - _ => None, - } -} - -fn extract_version_from_latest_tag(latest_tag_name: &str) -> anyhow::Result { - latest_tag_name - .strip_prefix("rust-v") - .map(str::to_owned) - .ok_or_else(|| anyhow::anyhow!("Failed to parse latest tag name '{latest_tag_name}'")) +async fn fetch_latest_github_release_version() -> anyhow::Result { + let ReleaseInfo { + tag_name: latest_tag_name, + } = create_client() + .get(LATEST_RELEASE_URL) + .send() + .await? + .error_for_status()? + .json::() + .await?; + extract_version_from_latest_tag(&latest_tag_name) } /// Returns the latest version to show in a popup, if it should be shown. @@ -168,68 +176,3 @@ pub async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<( tokio::fs::write(version_file, json_line).await?; Ok(()) } - -fn parse_version(v: &str) -> Option<(u64, u64, u64)> { - let mut iter = v.trim().split('.'); - let maj = iter.next()?.parse::().ok()?; - let min = iter.next()?.parse::().ok()?; - let pat = iter.next()?.parse::().ok()?; - Some((maj, min, pat)) -} - -fn is_source_build_version(version: &str) -> bool { - parse_version(version) == Some((0, 0, 0)) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn extract_version_from_brew_api_json() { - // - // https://formulae.brew.sh/api/cask/codex.json - let cask_json = r#"{ - "token": "codex", - "full_token": "codex", - "tap": "homebrew/cask", - "version": "0.96.0", - }"#; - let HomebrewCaskInfo { version } = serde_json::from_str::(cask_json) - .expect("failed to parse version from cask json"); - assert_eq!(version, "0.96.0"); - } - - #[test] - fn extracts_version_from_latest_tag() { - assert_eq!( - extract_version_from_latest_tag("rust-v1.5.0").expect("failed to parse version"), - "1.5.0" - ); - } - - #[test] - fn latest_tag_without_prefix_is_invalid() { - assert!(extract_version_from_latest_tag("v1.5.0").is_err()); - } - - #[test] - fn prerelease_version_is_not_considered_newer() { - assert_eq!(is_newer("0.11.0-beta.1", "0.11.0"), None); - assert_eq!(is_newer("1.0.0-rc.1", "1.0.0"), None); - } - - #[test] - fn plain_semver_comparisons_work() { - assert_eq!(is_newer("0.11.1", "0.11.0"), Some(true)); - assert_eq!(is_newer("0.11.0", "0.11.1"), Some(false)); - assert_eq!(is_newer("1.0.0", "0.9.9"), Some(true)); - assert_eq!(is_newer("0.9.9", "1.0.0"), Some(false)); - } - - #[test] - fn whitespace_is_ignored() { - assert_eq!(parse_version(" 1.2.3 \n"), Some((1, 2, 3))); - assert_eq!(is_newer(" 1.2.3 ", "1.2.2"), Some(true)); - } -} diff --git a/codex-rs/tui/src/width.rs b/codex-rs/tui/src/width.rs new file mode 100644 index 0000000000..a69cddb27b --- /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 c31326b10f..b205ead325 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 0000000000..53c1c5da94 --- /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() +}