mirror of
https://github.com/openai/codex.git
synced 2026-10-28 18:58:46 +00:00
Merge 869706f870 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
-2
@@ -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",
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:?}"),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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<()> {
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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:?}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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()?;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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());
|
||||
})?;
|
||||
|
||||
@@ -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
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user