Merge 869706f870 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-04-26 12:43:58 -07:00
committed by GitHub
53 changed files with 3744 additions and 756 deletions
+52 -4
View File
@@ -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
-2
View File
@@ -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",
-2
View File
@@ -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 }
+7 -21
View File
@@ -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 {
-246
View File
@@ -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",
})
);
}
}
+3
View File
@@ -81,6 +81,9 @@ pub enum ResponseEvent {
Completed {
response_id: String,
token_usage: Option<TokenUsage>,
/// 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<bool>,
},
OutputTextDelta(String),
ToolCallInputDelta {
+13 -3
View File
@@ -123,6 +123,8 @@ struct ResponseCompleted {
id: String,
#[serde(default)]
usage: Option<ResponseCompletedUsage>,
#[serde(default)]
end_turn: Option<bool>,
}
#[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"
);
}
@@ -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:?}"),
}
+10
View File
@@ -532,6 +532,9 @@ pub struct ModelAvailabilityNuxConfig {
pub shown_count: HashMap<String, u32>,
}
/// 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<usize>,
}
const fn default_true() -> bool {
+13
View File
@@ -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"
},
+2
View File
@@ -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()
+81
View File
@@ -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(),
+35
View File
@@ -466,6 +466,9 @@ pub struct Config {
/// Syntax highlighting theme override (kebab-case name).
pub tui_theme: Option<String>,
/// 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);
+4 -1
View File
@@ -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,
+12
View File
@@ -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",
+15 -1
View File
@@ -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);
+29 -7
View File
@@ -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<String>,
}
#[derive(Debug, Default)]
struct InitialHistoryReplayBuffer {
retained_lines: VecDeque<Line<'static>>,
}
pub(crate) struct App {
model_catalog: Arc<ModelCatalog>,
pub(crate) session_telemetry: SessionTelemetry,
@@ -509,6 +516,8 @@ pub(crate) struct App {
pub(crate) overlay: Option<Overlay>,
pub(crate) deferred_history_lines: Vec<Line<'static>>,
has_emitted_history_lines: bool,
transcript_reflow: TranscriptReflowState,
initial_history_replay_buffer: Option<InitialHistoryReplayBuffer>,
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<AppRunControl> {
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);
@@ -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<()> {
+78 -15
View File
@@ -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<dyn HistoryCell> = 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::<history_cell::AgentMessageCell>(&self.transcript_cells);
if start < end {
let consolidated: Arc<dyn HistoryCell> =
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::<history_cell::ProposedPlanStreamCell>(
&self.transcript_cells,
);
let consolidated: Arc<dyn HistoryCell> =
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 } => {
+6
View File
@@ -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;
}
+482
View File
@@ -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<Line<'static>>,
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<Line<'static>>,
}
pub(super) fn trailing_run_start<T: 'static>(transcript_cells: &[Arc<dyn HistoryCell>]) -> 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::<T>()
{
start -= 1;
}
if start > 0
&& transcript_cells[start - 1].as_any().is::<T>()
&& !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<Line<'static>> {
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::<Vec<_>>();
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<Line<'static>>,
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<u16>) -> bool {
debug_assert!(self.terminal_resize_reflow_enabled());
self.transcript_reflow.schedule_debounced(target_width)
}
fn resize_reflow_max_rows(&self) -> Option<usize> {
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<u16> {
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::<history_cell::AgentMessageCell>(&self.transcript_cells)
< self.transcript_cells.len()
|| trailing_run_start::<history_cell::ProposedPlanStreamCell>(&self.transcript_cells)
< self.transcript_cells.len()
}
}
+1 -6
View File
@@ -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(())
}
+2
View File
@@ -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)),
+147
View File
@@ -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<String>) -> Arc<dyn HistoryCell> {
Arc::new(PlainHistoryCell::new(vec![Line::from(text.into())])) as Arc<dyn HistoryCell>
}
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<_>>(),
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<_>>(),
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<_>>(),
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<ThreadItem>) -> Turn {
Turn {
id: turn_id.to_string(),
+11
View File
@@ -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 {
+1 -1
View File
@@ -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();
+26
View File
@@ -384,8 +384,34 @@ pub(crate) enum AppEvent {
result: Result<SkillsListResponse, String>,
},
/// Begin buffering initial resume replay rows before they are written to scrollback.
BeginInitialHistoryReplayBuffer,
InsertHistoryCell(Box<dyn HistoryCell>),
/// 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
+99 -12
View File
@@ -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<usize> {
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<AppCommand>,
{
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) {
+6 -2
View File
@@ -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();
+1 -1
View File
@@ -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());
})?;
@@ -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());
});
+283 -4
View File
@@ -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<Line<'static>>` 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<dyn HistoryCell> {
.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<Line<'static>> = 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<Line<'static>> {
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<Line<'static>> = 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<Line<'static>>,
@@ -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<Line<'static>>,
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<Line<'static>>,
@@ -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<dyn HistoryCell> = 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<dyn HistoryCell> =
Box::new(PlainHistoryCell::new(vec![Line::from("fresh")]));
second.render(area, &mut buf);
let mut rendered_rows: Vec<String> = 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<dyn HistoryCell>;
let head = Arc::new(AgentMessageCell::new(
vec![Line::from("line 1")],
/*is_first_line*/ true,
)) as Arc<dyn HistoryCell>;
let cont1 = Arc::new(AgentMessageCell::new(
vec![Line::from("line 2")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>;
let cont2 = Arc::new(AgentMessageCell::new(
vec![Line::from("line 3")],
/*is_first_line*/ false,
)) as Arc<dyn HistoryCell>;
let mut transcript_cells: Vec<Arc<dyn HistoryCell>> =
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::<AgentMessageCell>()
{
start -= 1;
}
if start > 0
&& transcript_cells[start - 1]
.as_any()
.is::<AgentMessageCell>()
&& !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<dyn HistoryCell> =
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::<UserHistoryCell>(),
"first cell should be UserHistoryCell"
);
// Verify second cell is AgentMarkdownCell.
assert!(
transcript_cells[1].as_any().is::<AgentMarkdownCell>(),
"second cell should be AgentMarkdownCell"
);
}
}
+86 -67
View File
@@ -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<B>(
terminal: &mut crate::custom_terminal::Terminal<B>,
lines: Vec<Line>,
@@ -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<String> = term.backend().vt100().screen().rows(0, width).collect();
let start_row = 0;
let rows: Vec<String> = 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:?}"
+7
View File
@@ -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 {
+148 -22
View File
@@ -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<usize>,
#[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<usize>, 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<usize>) {
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<String> {
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<Line<'static>> {
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<Line<'static>> = 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<Line<'static>> {
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;
}
}
+1 -1
View File
@@ -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());
});
+130
View File
@@ -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<String, String>,
versions: HashMap<String, NpmPackageVersionInfo>,
}
#[derive(Deserialize, Debug, Clone)]
struct NpmPackageVersionInfo {
dist: Option<NpmPackageDist>,
}
#[derive(Deserialize, Debug, Clone)]
struct NpmPackageDist {
tarball: Option<String>,
integrity: Option<String>,
}
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}"
);
}
}
@@ -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 {
+99 -2
View File
@@ -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<usize>,
consolidated: Arc<dyn HistoryCell>,
) {
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<dyn HistoryCell>
})
.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<dyn HistoryCell>
})
.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
+1
View File
@@ -26,6 +26,7 @@ pub fn push_owned_lines<'a>(src: &[Line<'a>], out: &mut Vec<Line<'static>>) {
/// 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;
+183
View File
@@ -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<usize> {
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<usize> {
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)
);
}
}
+1 -1
View File
@@ -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);
+424 -237
View File
@@ -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<usize>,
raw_source: String,
rendered_lines: Vec<Line<'static>>,
enqueued_len: usize,
emitted_len: usize,
cwd: PathBuf,
}
impl StreamCore {
fn new(width: Option<usize>, 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<Line<'static>> {
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<Line<'static>> {
let step = self.state.step();
self.emitted_len += step.len();
step
}
fn tick_batch(&mut self, max_lines: usize) -> Vec<Line<'static>> {
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<Duration> {
self.state.oldest_queued_age(now)
}
fn is_idle(&self) -> bool {
self.state.is_idle()
}
fn set_width(&mut self, width: Option<usize>) {
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<usize>, 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<Box<dyn HistoryCell>> {
// 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<Box<dyn HistoryCell>>, 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<Box<dyn HistoryCell>>, Option<String>) {
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<Box<dyn HistoryCell>>, 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<Box<dyn HistoryCell>>, 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<Duration> {
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<usize>) {
self.core.set_width(width);
}
fn emit(&mut self, lines: Vec<Line<'static>>) -> Option<Box<dyn HistoryCell>> {
@@ -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<usize>, 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<Box<dyn HistoryCell>> {
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<Box<dyn HistoryCell>>, Option<String>) {
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<Box<dyn HistoryCell>>, 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<Box<dyn HistoryCell>>, 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<Duration> {
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<usize>) {
self.core.set_width(width);
}
fn emit(
@@ -213,7 +394,7 @@ impl PlanStreamController {
return None;
}
let mut out_lines: Vec<Line<'static>> = Vec::new();
let mut out_lines: Vec<Line<'static>> = 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<Line<'static>> = Vec::new();
let mut plan_lines: Vec<Line<'static>> = 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<String> {
fn stream_controller(width: Option<usize>) -> StreamController {
StreamController::new(width, &test_cwd())
}
fn plan_stream_controller(width: Option<usize>) -> PlanStreamController {
PlanStreamController::new(width, &test_cwd())
}
fn lines_to_plain_strings(lines: &[Line<'_>]) -> Vec<String> {
lines
.iter()
.map(|l| {
l.spans
.map(|line| {
line.spans
.iter()
.map(|s| s.content.clone())
.collect::<Vec<_>>()
.join("")
.map(|span| span.content.clone())
.collect::<String>()
})
.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<usize>) -> Vec<String> {
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::<String>())
.collect();
.map(|line| line.chars().skip(2).collect::<String>())
.collect()
}
// Full render of the same source
let source: String = deltas.iter().copied().collect();
let mut rendered: Vec<ratatui::text::Line<'static>> = 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<usize>) -> Vec<String> {
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:?}",
);
}
}
+3 -6
View File
@@ -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<Line<'static>> {
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 {
+302
View File
@@ -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<u16>,
last_reflow_width: Option<u16>,
pending_reflow_width: Option<u16>,
pending_until: Option<Instant>,
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<u16>) -> 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<Instant> {
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());
}
}
+115 -1
View File
@@ -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<bool> {
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<Option<Rect>> {
let terminal = &mut self.terminal;
let screen_size = terminal.size()?;
+12 -1
View File
@@ -244,7 +244,7 @@ impl<S: EventSource + Default + Unpin> TuiEventStream<S> {
}
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();
+1 -1
View File
@@ -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());
})?;
+70
View File
@@ -0,0 +1,70 @@
pub(crate) fn is_newer(latest: &str, current: &str) -> Option<bool> {
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<String> {
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::<u64>().ok()?;
let min = iter.next()?.parse::<u64>().ok()?;
let pat = iter.next()?.parse::<u64>().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));
}
}
+30 -87
View File
@@ -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<String> {
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<String> {
// 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<VersionInfo> {
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<UpdateAction>) -> 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::<ReleaseInfo>()
.json::<NpmPackageInfo>()
.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<bool> {
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<String> {
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<String> {
let ReleaseInfo {
tag_name: latest_tag_name,
} = create_client()
.get(LATEST_RELEASE_URL)
.send()
.await?
.error_for_status()?
.json::<ReleaseInfo>()
.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::<u64>().ok()?;
let min = iter.next()?.parse::<u64>().ok()?;
let pat = iter.next()?.parse::<u64>().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::<HomebrewCaskInfo>(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));
}
}
+72
View File
@@ -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<usize> {
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<usize> {
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)
);
}
}
+1
View File
@@ -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;
+613
View File
@@ -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<PathBuf> {
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<String> {
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<String> {
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<usize> {
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<usize> {
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<Output> {
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<Output> {
command
.output()
.with_context(|| format!("failed to run {command:?}"))
}
fn stdout_text(output: &Output) -> String {
String::from_utf8_lossy(&output.stdout).to_string()
}