diff --git a/AGENTS.md b/AGENTS.md index f1744a87e5..eb2cacd50e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,7 @@ In the codex-rs folder where the rust code lives: - You operate in a sandbox where `CODEX_SANDBOX_NETWORK_DISABLED=1` will be set whenever you use the `shell` tool. Any existing code that uses `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` was authored with this fact in mind. It is often used to early exit out of tests that the author knew you would not be able to run given your sandbox limitations. - Similarly, when you spawn a process using Seatbelt (`/usr/bin/sandbox-exec`), `CODEX_SANDBOX=seatbelt` will be set on the child process. Integration tests that want to run Seatbelt themselves cannot be run under Seatbelt, so checks for `CODEX_SANDBOX=seatbelt` are also often used to early exit out of tests, as appropriate. -Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests: +Before finalizing a change to `codex-rs`, run `just fmt` (in `codex-rs` directory) to format the code and `just fix -p ` (in `codex-rs` directory) to fix any linter issues in the code. Additionally, run the tests: 1. Run the test for the specific project that was changed. For example, if changes were made in `codex-rs/tui`, run `cargo test -p codex-tui`. 2. Once those pass, if any changes were made in common, core, or protocol, run the complete test suite with `cargo test --all-features`. diff --git a/README.md b/README.md index f7428dc35e..2ff4c87eb7 100644 --- a/README.md +++ b/README.md @@ -383,6 +383,13 @@ base_url = "http://my-ollama.example.com:11434/v1" ### Platform sandboxing details +By default, Codex CLI runs code and shell commands inside a restricted sandbox to protect your system. + +> [!IMPORTANT] +> Not all tool calls are sandboxed. Specifically, **trusted Model Context Protocol (MCP) tool calls** are executed outside of the sandbox. +> This is intentional: MCP tools are explicitly configured and trusted by you, and they often need to connect to **external applications or services** (e.g. issue trackers, databases, messaging systems). +> Running them outside the sandbox allows Codex to integrate with these external systems without being blocked by sandbox restrictions. + The mechanism Codex uses to implement the sandbox policy depends on your OS: - **macOS 12+** uses **Apple Seatbelt** and runs commands using `sandbox-exec` with a profile (`-p`) that corresponds to the `--sandbox` that was specified. diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index dc9385d990..18a4d04ad1 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -711,6 +711,7 @@ dependencies = [ "mime_guess", "openssl-sys", "os_info", + "portable-pty", "predicates", "pretty_assertions", "rand 0.9.2", @@ -737,6 +738,7 @@ dependencies = [ "tree-sitter-bash", "uuid", "walkdir", + "which", "whoami", "wildmatch", "wiremock", @@ -822,6 +824,7 @@ version = "0.0.0" dependencies = [ "base64 0.22.1", "chrono", + "codex-protocol", "pretty_assertions", "rand 0.8.5", "reqwest", @@ -926,6 +929,7 @@ name = "codex-tui" version = "0.0.0" dependencies = [ "anyhow", + "async-stream", "base64 0.22.1", "chrono", "clap", @@ -961,6 +965,7 @@ dependencies = [ "supports-color", "textwrap 0.16.2", "tokio", + "tokio-stream", "tracing", "tracing-appender", "tracing-subscriber", @@ -1161,6 +1166,7 @@ checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ "bitflags 2.9.1", "crossterm_winapi", + "futures-core", "mio", "parking_lot", "rustix 0.38.44", @@ -1438,6 +1444,12 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + [[package]] name = "dupe" version = "0.9.1" @@ -1683,6 +1695,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "filedescriptor" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40758ed24c9b2eeb76c35fb0aebc66c626084edd827e07e1552279814c6682d" +dependencies = [ + "libc", + "thiserror 1.0.69", + "winapi", +] + [[package]] name = "fixedbitset" version = "0.4.2" @@ -3340,6 +3363,27 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "portable-pty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e" +dependencies = [ + "anyhow", + "bitflags 1.3.2", + "downcast-rs", + "filedescriptor", + "lazy_static", + "libc", + "log", + "nix", + "serial2", + "shared_library", + "shell-words", + "winapi", + "winreg", +] + [[package]] name = "potential_utf" version = "0.1.2" @@ -4267,6 +4311,17 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "serial2" +version = "0.2.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26e1e5956803a69ddd72ce2de337b577898801528749565def03515f82bad5bb" +dependencies = [ + "cfg-if", + "libc", + "winapi", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4298,6 +4353,22 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24188a676b6ae68c3b2cb3a01be17fbf7240ce009799bb56d5b1409051e78fde" + [[package]] name = "shlex" version = "1.3.0" @@ -5596,6 +5667,18 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a751b3277700db47d3e574514de2eced5e54dc8a5436a3bf7a0b248b2cee16f3" +[[package]] +name = "which" +version = "6.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ee928febd44d98f2f459a4a79bd4d928591333a494a10a868418ac1b39cf1f" +dependencies = [ + "either", + "home", + "rustix 0.38.44", + "winsafe", +] + [[package]] name = "whoami" version = "1.6.0" @@ -6008,6 +6091,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d0f4e272c85def139476380b12f9ac60926689dd2e01d4923222f40580869d" +dependencies = [ + "winapi", +] + +[[package]] +name = "winsafe" +version = "0.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d135d17ab770252ad95e9a872d365cf3090e3be864a34ab46f48555993efc904" + [[package]] name = "wiremock" version = "0.6.4" diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index 1aae106682..15966ac29c 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -22,6 +22,8 @@ use tree_sitter_bash::LANGUAGE as BASH; /// Detailed instructions for gpt-4.1 on how to use the `apply_patch` tool. pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); +const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; + #[derive(Debug, Error, PartialEq)] pub enum ApplyPatchError { #[error(transparent)] @@ -82,7 +84,6 @@ pub struct ApplyPatchArgs { } pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { - const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; match argv { [cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) { Ok(source) => MaybeApplyPatch::Body(source), @@ -91,7 +92,9 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { [bash, flag, script] if bash == "bash" && flag == "-lc" - && script.trim_start().starts_with("apply_patch") => + && APPLY_PATCH_COMMANDS + .iter() + .any(|cmd| script.trim_start().starts_with(cmd)) => { match extract_heredoc_body_from_apply_patch_command(script) { Ok(body) => match parse_patch(&body) { @@ -262,7 +265,10 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp fn extract_heredoc_body_from_apply_patch_command( src: &str, ) -> std::result::Result { - if !src.trim_start().starts_with("apply_patch") { + if !APPLY_PATCH_COMMANDS + .iter() + .any(|cmd| src.trim_start().starts_with(cmd)) + { return Err(ExtractHeredocError::CommandDidNotStartWithApplyPatch); } @@ -773,6 +779,33 @@ PATCH"#, } } + #[test] + fn test_heredoc_applypatch() { + let args = strs_to_strings(&[ + "bash", + "-lc", + r#"applypatch <<'PATCH' +*** Begin Patch +*** Add File: foo ++hi +*** End Patch +PATCH"#, + ]); + + match maybe_parse_apply_patch(&args) { + MaybeApplyPatch::Body(ApplyPatchArgs { hunks, patch: _ }) => { + assert_eq!( + hunks, + vec![Hunk::AddFile { + path: PathBuf::from("foo"), + contents: "hi\n".to_string() + }] + ); + } + result => panic!("expected MaybeApplyPatch::Body got {result:?}"), + } + } + #[test] fn test_add_file_hunk_creates_file_with_contents() { let dir = tempdir().unwrap(); diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index d237fe6729..2acc3d84c5 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -159,7 +159,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() codex_exec::run_main(exec_cli, codex_linux_sandbox_exe).await?; } Some(Subcommand::Mcp) => { - codex_mcp_server::run_main(codex_linux_sandbox_exe).await?; + codex_mcp_server::run_main(codex_linux_sandbox_exe, cli.config_overrides).await?; } Some(Subcommand::Login(mut login_cli)) => { prepend_config_flags(&mut login_cli.config_overrides, cli.config_overrides); diff --git a/codex-rs/common/src/approval_presets.rs b/codex-rs/common/src/approval_presets.rs index 6c3bf395ad..6dc0cff0ee 100644 --- a/codex-rs/common/src/approval_presets.rs +++ b/codex-rs/common/src/approval_presets.rs @@ -24,21 +24,21 @@ pub fn builtin_approval_presets() -> Vec { ApprovalPreset { id: "read-only", label: "Read Only", - description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network.", + description: "Codex can read files and answer questions. Codex requires approval to make edits, run commands, or access network", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::ReadOnly, }, ApprovalPreset { id: "auto", label: "Auto", - description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network.", + description: "Codex can read files, make edits, and run commands in the workspace. Codex requires approval to work outside the workspace or access network", approval: AskForApproval::OnRequest, sandbox: SandboxPolicy::new_workspace_write_policy(), }, ApprovalPreset { id: "full-access", label: "Full Access", - description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution.", + description: "Codex can read files, make edits, and run commands with network access, without approval. Exercise caution", approval: AskForApproval::Never, sandbox: SandboxPolicy::DangerFullAccess, }, diff --git a/codex-rs/common/src/model_presets.rs b/codex-rs/common/src/model_presets.rs index 16ec9be199..686a2c0291 100644 --- a/codex-rs/common/src/model_presets.rs +++ b/codex-rs/common/src/model_presets.rs @@ -24,28 +24,28 @@ pub fn builtin_model_presets() -> &'static [ModelPreset] { ModelPreset { id: "gpt-5-minimal", label: "gpt-5 minimal", - description: "— Fastest responses with very limited reasoning; ideal for coding, instructions, or lightweight tasks.", + description: "— fastest responses with limited reasoning; ideal for coding, instructions, or lightweight tasks", model: "gpt-5", effort: ReasoningEffort::Minimal, }, ModelPreset { id: "gpt-5-low", label: "gpt-5 low", - description: "— Balances speed with some reasoning; useful for straightforward queries and short explanations.", + description: "— balances speed with some reasoning; useful for straightforward queries and short explanations", model: "gpt-5", effort: ReasoningEffort::Low, }, ModelPreset { id: "gpt-5-medium", label: "gpt-5 medium", - description: "— Default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks.", + description: "— default setting; provides a solid balance of reasoning depth and latency for general-purpose tasks", model: "gpt-5", effort: ReasoningEffort::Medium, }, ModelPreset { id: "gpt-5-high", label: "gpt-5 high", - description: "— Maximizes reasoning depth for complex or ambiguous problems.", + description: "— maximizes reasoning depth for complex or ambiguous problems", model: "gpt-5", effort: ReasoningEffort::High, }, diff --git a/codex-rs/core/Cargo.toml b/codex-rs/core/Cargo.toml index 52c731ae66..2f2fa7cbad 100644 --- a/codex-rs/core/Cargo.toml +++ b/codex-rs/core/Cargo.toml @@ -28,6 +28,7 @@ libc = "0.2.175" mcp-types = { path = "../mcp-types" } mime_guess = "2.0" os_info = "3.12.0" +portable-pty = "0.9.0" rand = "0.9" regex-lite = "0.1.6" reqwest = { version = "0.12", features = ["json", "stream"] } @@ -71,6 +72,9 @@ openssl-sys = { version = "*", features = ["vendored"] } [target.aarch64-unknown-linux-musl.dependencies] openssl-sys = { version = "*", features = ["vendored"] } +[target.'cfg(target_os = "windows")'.dependencies] +which = "6" + [dev-dependencies] assert_cmd = "2" core_test_support = { path = "tests/common" } diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9552a53d7f..5534e11f36 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -248,12 +248,10 @@ impl ModelClient { .and_then(|v| v.to_str().ok()) .and_then(|s| s.parse::().ok()); - if status == StatusCode::UNAUTHORIZED { - if let Some(a) = auth.as_ref() { - let _ = a.refresh_token().await; - } - // Retry immediately with refreshed credentials. - continue; + if status == StatusCode::UNAUTHORIZED + && let Some(a) = auth.as_ref() + { + let _ = a.refresh_token().await; } // The OpenAI Responses endpoint returns structured JSON bodies even for 4xx/5xx @@ -263,7 +261,10 @@ impl ModelClient { // exact error message (e.g. "Unknown parameter: 'input[0].metadata'"). The body is // small and this branch only runs on error paths so the extra allocation is // negligible. - if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) { + if !(status == StatusCode::TOO_MANY_REQUESTS + || status == StatusCode::UNAUTHORIZED + || status.is_server_error()) + { // Surface the error body to callers. Use `unwrap_or_default` per Clippy. let body = res.text().await.unwrap_or_default(); return Err(CodexErr::UnexpectedStatus(status, body)); diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 57719f5269..01acd5c6e5 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -52,6 +52,11 @@ use crate::exec::SandboxType; use crate::exec::StdoutStream; use crate::exec::StreamOutput; use crate::exec::process_exec_tool_call; +use crate::exec_command::EXEC_COMMAND_TOOL_NAME; +use crate::exec_command::ExecCommandParams; +use crate::exec_command::SESSION_MANAGER; +use crate::exec_command::WRITE_STDIN_TOOL_NAME; +use crate::exec_command::WriteStdinParams; use crate::exec_env::create_env; use crate::mcp_connection_manager::McpConnectionManager; use crate::mcp_tool_call::handle_mcp_tool_call; @@ -94,6 +99,7 @@ use crate::protocol::PatchApplyEndEvent; use crate::protocol::ReviewDecision; use crate::protocol::SandboxPolicy; use crate::protocol::SessionConfiguredEvent; +use crate::protocol::StreamErrorEvent; use crate::protocol::Submission; use crate::protocol::TaskCompleteEvent; use crate::protocol::TurnDiffEvent; @@ -480,6 +486,7 @@ impl Session { sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.use_experimental_streamable_shell_tool, ), user_instructions, base_instructions, @@ -508,9 +515,10 @@ impl Session { conversation_items.push(Prompt::format_user_instructions_message(user_instructions)); } conversation_items.push(ResponseItem::from(EnvironmentContext::new( - turn_context.cwd.to_path_buf(), - turn_context.approval_policy, - turn_context.sandbox_policy.clone(), + Some(turn_context.cwd.clone()), + Some(turn_context.approval_policy), + Some(turn_context.sandbox_policy.clone()), + Some(sess.user_shell.clone()), ))); sess.record_conversation_items(&conversation_items).await; @@ -814,6 +822,16 @@ impl Session { let _ = self.tx_event.send(event).await; } + async fn notify_stream_error(&self, sub_id: &str, message: impl Into) { + let event = Event { + id: sub_id.to_string(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: message.into(), + }), + }; + let _ = self.tx_event.send(event).await; + } + /// Build the full turn input by concatenating the current conversation /// history with additional items for this turn. pub fn turn_input_with_history(&self, extra: Vec) -> Vec { @@ -1049,6 +1067,7 @@ async fn submission_loop( new_sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.use_experimental_streamable_shell_tool, ); let new_turn_context = TurnContext { @@ -1067,9 +1086,11 @@ async fn submission_loop( turn_context = Arc::new(new_turn_context); if cwd.is_some() || approval_policy.is_some() || sandbox_policy.is_some() { sess.record_conversation_items(&[ResponseItem::from(EnvironmentContext::new( - new_cwd, - new_approval_policy, - new_sandbox_policy, + cwd, + approval_policy, + sandbox_policy, + // Shell is not configurable from turn to turn + None, ))]) .await; } @@ -1125,6 +1146,7 @@ async fn submission_loop( sandbox_policy.clone(), config.include_plan_tool, config.include_apply_patch_tool, + config.use_experimental_streamable_shell_tool, ), user_instructions: turn_context.user_instructions.clone(), base_instructions: turn_context.base_instructions.clone(), @@ -1520,7 +1542,7 @@ async fn run_turn( // Surface retry information to any UI/front‑end so the // user understands what is happening instead of staring // at a seemingly frozen screen. - sess.notify_background_event( + sess.notify_stream_error( &sub_id, format!( "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" @@ -1755,7 +1777,7 @@ async fn run_compact_task( if retries < max_retries { retries += 1; let delay = backoff(retries); - sess.notify_background_event( + sess.notify_stream_error( &sub_id, format!( "stream error: {e}; retrying {retries}/{max_retries} in {delay:?}…" @@ -1985,6 +2007,41 @@ async fn handle_function_call( .await } "update_plan" => handle_update_plan(sess, arguments, sub_id, call_id).await, + EXEC_COMMAND_TOOL_NAME => { + // TODO(mbolin): Sandbox check. + let exec_params = match serde_json::from_str::(&arguments) { + Ok(params) => params, + Err(e) => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("failed to parse function arguments: {e}"), + success: Some(false), + }, + }; + } + }; + SESSION_MANAGER + .handle_exec_command_request(call_id, exec_params) + .await + } + WRITE_STDIN_TOOL_NAME => { + let write_stdin_params = match serde_json::from_str::(&arguments) { + Ok(params) => params, + Err(e) => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("failed to parse function arguments: {e}"), + success: Some(false), + }, + }; + } + }; + SESSION_MANAGER + .handle_write_stdin_request(call_id, write_stdin_params) + .await + } _ => { match sess.mcp_connection_manager.parse_tool_name(&name) { Some((server, tool_name)) => { @@ -2051,18 +2108,20 @@ pub struct ExecInvokeArgs<'a> { pub stdout_stream: Option, } -fn maybe_run_with_user_profile( +fn maybe_translate_shell_command( params: ExecParams, sess: &Session, turn_context: &TurnContext, ) -> ExecParams { - if turn_context.shell_environment_policy.use_profile { - let command = sess + let should_translate = matches!(sess.user_shell, crate::shell::Shell::PowerShell(_)) + || turn_context.shell_environment_policy.use_profile; + + if should_translate + && let Some(command) = sess .user_shell - .format_default_shell_invocation(params.command.clone()); - if let Some(command) = command { - return ExecParams { command, ..params }; - } + .format_default_shell_invocation(params.command.clone()) + { + return ExecParams { command, ..params }; } params } @@ -2227,7 +2286,7 @@ async fn handle_container_exec_with_params( ), }; - let params = maybe_run_with_user_profile(params, sess, turn_context); + let params = maybe_translate_shell_command(params, sess, turn_context); let output_result = sess .run_exec_with_events( turn_diff_tracker, diff --git a/codex-rs/core/src/config.rs b/codex-rs/core/src/config.rs index 4b6f8ac9ef..241f80eb7b 100644 --- a/codex-rs/core/src/config.rs +++ b/codex-rs/core/src/config.rs @@ -169,6 +169,8 @@ pub struct Config { /// If set to `true`, the API key will be signed with the `originator` header. pub preferred_auth_method: AuthMode, + + pub use_experimental_streamable_shell_tool: bool, } impl Config { @@ -259,10 +261,53 @@ pub fn set_project_trusted(codex_home: &Path, project_path: &Path) -> anyhow::Re Err(e) => return Err(e.into()), }; - // Mark the project as trusted. toml_edit is very good at handling - // missing properties + // Ensure we render a human-friendly structure: + // + // [projects] + // [projects."/path/to/project"] + // trust_level = "trusted" + // + // rather than inline tables like: + // + // [projects] + // "/path/to/project" = { trust_level = "trusted" } let project_key = project_path.to_string_lossy().to_string(); - doc["projects"][project_key.as_str()]["trust_level"] = toml_edit::value("trusted"); + + // Ensure top-level `projects` exists as a non-inline, explicit table. If it + // exists but was previously represented as a non-table (e.g., inline), + // replace it with an explicit table. + { + let root = doc.as_table_mut(); + let needs_table = !root.contains_key("projects") + || root.get("projects").and_then(|i| i.as_table()).is_none(); + if needs_table { + root.insert("projects", toml_edit::table()); + } + } + let Some(projects_tbl) = doc["projects"].as_table_mut() else { + return Err(anyhow::anyhow!( + "projects table missing after initialization" + )); + }; + + // Ensure the per-project entry is its own explicit table. If it exists but + // is not a table (e.g., an inline table), replace it with an explicit table. + let needs_proj_table = !projects_tbl.contains_key(project_key.as_str()) + || projects_tbl + .get(project_key.as_str()) + .and_then(|i| i.as_table()) + .is_none(); + if needs_proj_table { + projects_tbl.insert(project_key.as_str(), toml_edit::table()); + } + let Some(proj_tbl) = projects_tbl + .get_mut(project_key.as_str()) + .and_then(|i| i.as_table_mut()) + else { + return Err(anyhow::anyhow!("project table missing for {}", project_key)); + }; + proj_tbl.set_implicit(false); + proj_tbl["trust_level"] = toml_edit::value("trusted"); // ensure codex_home exists std::fs::create_dir_all(codex_home)?; @@ -411,6 +456,8 @@ pub struct ConfigToml { /// Experimental path to a file whose contents replace the built-in BASE_INSTRUCTIONS. pub experimental_instructions_file: Option, + pub experimental_use_exec_command_tool: Option, + /// The value for the `originator` header included with Responses API requests. pub responses_originator_header_internal_override: Option, @@ -686,6 +733,9 @@ impl Config { include_apply_patch_tool: include_apply_patch_tool_val, responses_originator_header, preferred_auth_method: cfg.preferred_auth_method.unwrap_or(AuthMode::ChatGPT), + use_experimental_streamable_shell_tool: cfg + .experimental_use_exec_command_tool + .unwrap_or(false), }; Ok(config) } @@ -1051,6 +1101,7 @@ disable_response_storage = true include_apply_patch_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, + use_experimental_streamable_shell_tool: false, }, o3_profile_config ); @@ -1104,6 +1155,7 @@ disable_response_storage = true include_apply_patch_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, + use_experimental_streamable_shell_tool: false, }; assert_eq!(expected_gpt3_profile_config, gpt3_profile_config); @@ -1172,10 +1224,103 @@ disable_response_storage = true include_apply_patch_tool: false, responses_originator_header: "codex_cli_rs".to_string(), preferred_auth_method: AuthMode::ChatGPT, + use_experimental_streamable_shell_tool: false, }; assert_eq!(expected_zdr_profile_config, zdr_profile_config); Ok(()) } + + #[test] + fn test_set_project_trusted_writes_explicit_tables() -> anyhow::Result<()> { + let codex_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + + // Call the function under test + set_project_trusted(codex_home.path(), project_dir.path())?; + + // Read back the generated config.toml + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let contents = std::fs::read_to_string(&config_path)?; + + // Verify it does not use inline tables for the project entry + assert!( + !contents.contains("{ trust_level"), + "config.toml should not use inline tables:\n{}", + contents + ); + + // Verify the explicit table for the project exists. toml_edit may choose + // either basic (double-quoted) or literal (single-quoted) strings for keys + // containing backslashes (e.g., on Windows). Accept both forms. + let path_str = project_dir.path().to_string_lossy(); + let project_key_double = format!("[projects.\"{}\"]", path_str); + let project_key_single = format!("[projects.'{}']", path_str); + assert!( + contents.contains(&project_key_double) || contents.contains(&project_key_single), + "missing explicit project table header: expected to find `{}` or `{}` in:\n{}", + project_key_double, + project_key_single, + contents + ); + + // Verify the trust_level entry + assert!( + contents.contains("trust_level = \"trusted\""), + "missing trust_level entry in:\n{}", + contents + ); + + Ok(()) + } + + #[test] + fn test_set_project_trusted_converts_inline_to_explicit() -> anyhow::Result<()> { + let codex_home = TempDir::new().unwrap(); + let project_dir = TempDir::new().unwrap(); + + // Seed config.toml with an inline project entry under [projects] + let config_path = codex_home.path().join(CONFIG_TOML_FILE); + let path_str = project_dir.path().to_string_lossy(); + // Use a literal-quoted key so Windows backslashes don't require escaping + let initial = format!( + "[projects]\n'{}' = {{ trust_level = \"untrusted\" }}\n", + path_str + ); + std::fs::create_dir_all(codex_home.path())?; + std::fs::write(&config_path, initial)?; + + // Run the function; it should convert to explicit tables and set trusted + set_project_trusted(codex_home.path(), project_dir.path())?; + + let contents = std::fs::read_to_string(&config_path)?; + + // Should not contain inline table representation anymore (accept both quote styles) + let inline_double = format!("\"{}\" = {{ trust_level = \"trusted\" }}", path_str); + let inline_single = format!("'{}' = {{ trust_level = \"trusted\" }}", path_str); + assert!( + !contents.contains(&inline_double) && !contents.contains(&inline_single), + "config.toml should not contain inline project table anymore:\n{}", + contents + ); + + // And explicit child table header for the project + let project_key_double = format!("[projects.\"{}\"]", path_str); + let project_key_single = format!("[projects.'{}']", path_str); + assert!( + contents.contains(&project_key_double) || contents.contains(&project_key_single), + "missing explicit project table header: expected to find `{}` or `{}` in:\n{}", + project_key_double, + project_key_single, + contents + ); + + // And the trust level value + assert!(contents.contains("trust_level = \"trusted\"")); + + Ok(()) + } + + // No test enforcing the presence of a standalone [projects] header. } diff --git a/codex-rs/core/src/environment_context.rs b/codex-rs/core/src/environment_context.rs index b37645a32b..c89d7ca6c7 100644 --- a/codex-rs/core/src/environment_context.rs +++ b/codex-rs/core/src/environment_context.rs @@ -6,12 +6,12 @@ use crate::models::ContentItem; use crate::models::ResponseItem; use crate::protocol::AskForApproval; use crate::protocol::SandboxPolicy; +use crate::shell::Shell; use codex_protocol::config_types::SandboxMode; -use std::fmt::Display; use std::path::PathBuf; /// wraps environment context message in a tag for the model to parse more easily. -pub(crate) const ENVIRONMENT_CONTEXT_START: &str = "\n"; +pub(crate) const ENVIRONMENT_CONTEXT_START: &str = ""; pub(crate) const ENVIRONMENT_CONTEXT_END: &str = ""; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, DeriveDisplay)] @@ -24,52 +24,87 @@ pub enum NetworkAccess { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[serde(rename = "environment_context", rename_all = "snake_case")] pub(crate) struct EnvironmentContext { - pub cwd: PathBuf, - pub approval_policy: AskForApproval, - pub sandbox_mode: SandboxMode, - pub network_access: NetworkAccess, + pub cwd: Option, + pub approval_policy: Option, + pub sandbox_mode: Option, + pub network_access: Option, + pub shell: Option, } impl EnvironmentContext { pub fn new( - cwd: PathBuf, - approval_policy: AskForApproval, - sandbox_policy: SandboxPolicy, + cwd: Option, + approval_policy: Option, + sandbox_policy: Option, + shell: Option, ) -> Self { Self { cwd, approval_policy, sandbox_mode: match sandbox_policy { - SandboxPolicy::DangerFullAccess => SandboxMode::DangerFullAccess, - SandboxPolicy::ReadOnly => SandboxMode::ReadOnly, - SandboxPolicy::WorkspaceWrite { .. } => SandboxMode::WorkspaceWrite, + Some(SandboxPolicy::DangerFullAccess) => Some(SandboxMode::DangerFullAccess), + Some(SandboxPolicy::ReadOnly) => Some(SandboxMode::ReadOnly), + Some(SandboxPolicy::WorkspaceWrite { .. }) => Some(SandboxMode::WorkspaceWrite), + None => None, }, network_access: match sandbox_policy { - SandboxPolicy::DangerFullAccess => NetworkAccess::Enabled, - SandboxPolicy::ReadOnly => NetworkAccess::Restricted, - SandboxPolicy::WorkspaceWrite { network_access, .. } => { + Some(SandboxPolicy::DangerFullAccess) => Some(NetworkAccess::Enabled), + Some(SandboxPolicy::ReadOnly) => Some(NetworkAccess::Restricted), + Some(SandboxPolicy::WorkspaceWrite { network_access, .. }) => { if network_access { - NetworkAccess::Enabled + Some(NetworkAccess::Enabled) } else { - NetworkAccess::Restricted + Some(NetworkAccess::Restricted) } } + None => None, }, + shell, } } } -impl Display for EnvironmentContext { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - writeln!( - f, - "Current working directory: {}", - self.cwd.to_string_lossy() - )?; - writeln!(f, "Approval policy: {}", self.approval_policy)?; - writeln!(f, "Sandbox mode: {}", self.sandbox_mode)?; - writeln!(f, "Network access: {}", self.network_access)?; - Ok(()) +impl EnvironmentContext { + /// Serializes the environment context to XML. Libraries like `quick-xml` + /// require custom macros to handle Enums with newtypes, so we just do it + /// manually, to keep things simple. Output looks like: + /// + /// ```xml + /// + /// ... + /// ... + /// ... + /// ... + /// ... + /// + /// ``` + pub fn serialize_to_xml(self) -> String { + let mut lines = vec![ENVIRONMENT_CONTEXT_START.to_string()]; + if let Some(cwd) = self.cwd { + lines.push(format!(" {}", cwd.to_string_lossy())); + } + if let Some(approval_policy) = self.approval_policy { + lines.push(format!( + " {}", + approval_policy + )); + } + if let Some(sandbox_mode) = self.sandbox_mode { + lines.push(format!(" {}", sandbox_mode)); + } + if let Some(network_access) = self.network_access { + lines.push(format!( + " {}", + network_access + )); + } + if let Some(shell) = self.shell + && let Some(shell_name) = shell.name() + { + lines.push(format!(" {}", shell_name)); + } + lines.push(ENVIRONMENT_CONTEXT_END.to_string()); + lines.join("\n") } } @@ -79,7 +114,7 @@ impl From for ResponseItem { id: None, role: "user".to_string(), content: vec![ContentItem::InputText { - text: format!("{ENVIRONMENT_CONTEXT_START}{ec}{ENVIRONMENT_CONTEXT_END}"), + text: ec.serialize_to_xml(), }], } } diff --git a/codex-rs/core/src/exec_command/exec_command_params.rs b/codex-rs/core/src/exec_command/exec_command_params.rs new file mode 100644 index 0000000000..11a3fd4596 --- /dev/null +++ b/codex-rs/core/src/exec_command/exec_command_params.rs @@ -0,0 +1,57 @@ +use serde::Deserialize; +use serde::Serialize; + +use crate::exec_command::session_id::SessionId; + +#[derive(Debug, Clone, Deserialize)] +pub struct ExecCommandParams { + pub(crate) cmd: String, + + #[serde(default = "default_yield_time")] + pub(crate) yield_time_ms: u64, + + #[serde(default = "max_output_tokens")] + pub(crate) max_output_tokens: u64, + + #[serde(default = "default_shell")] + pub(crate) shell: String, + + #[serde(default = "default_login")] + pub(crate) login: bool, +} + +fn default_yield_time() -> u64 { + 10_000 +} + +fn max_output_tokens() -> u64 { + 10_000 +} + +fn default_login() -> bool { + true +} + +fn default_shell() -> String { + "/bin/bash".to_string() +} + +#[derive(Debug, Deserialize, Serialize)] +pub struct WriteStdinParams { + pub(crate) session_id: SessionId, + pub(crate) chars: String, + + #[serde(default = "write_stdin_default_yield_time_ms")] + pub(crate) yield_time_ms: u64, + + #[serde(default = "write_stdin_default_max_output_tokens")] + pub(crate) max_output_tokens: u64, +} + +fn write_stdin_default_yield_time_ms() -> u64 { + 250 +} + +fn write_stdin_default_max_output_tokens() -> u64 { + 10_000 +} diff --git a/codex-rs/core/src/exec_command/exec_command_session.rs b/codex-rs/core/src/exec_command/exec_command_session.rs new file mode 100644 index 0000000000..131ab84d96 --- /dev/null +++ b/codex-rs/core/src/exec_command/exec_command_session.rs @@ -0,0 +1,54 @@ +use std::sync::Arc; + +use tokio::sync::Mutex; +use tokio::sync::mpsc; + +use crate::exec_command::session_id::SessionId; + +#[allow(dead_code)] +#[derive(Debug)] +pub(crate) struct ExecCommandSession { + pub(crate) id: SessionId, + /// Queue for writing bytes to the process stdin (PTY master write side). + writer_tx: mpsc::Sender>, + /// Stream of output chunks read from the PTY. Wrapped in Mutex so callers can + /// `await` receiving without needing `&mut self`. + output_rx: Arc>>>, +} + +#[allow(dead_code)] +impl ExecCommandSession { + pub(crate) fn new( + id: SessionId, + writer_tx: mpsc::Sender>, + output_rx: mpsc::Receiver>, + ) -> Self { + Self { + id, + writer_tx, + output_rx: Arc::new(Mutex::new(output_rx)), + } + } + + /// Enqueue bytes to be written to the process stdin (PTY master). + pub(crate) async fn write_stdin(&self, bytes: impl AsRef<[u8]>) -> anyhow::Result<()> { + self.writer_tx + .send(bytes.as_ref().to_vec()) + .await + .map_err(|e| anyhow::anyhow!("failed to send to writer: {e}")) + } + + /// Receive the next chunk of output from the process. Returns `None` when the + /// output stream is closed (process exited or reader finished). + pub(crate) async fn recv_output_chunk(&self) -> Option> { + self.output_rx.lock().await.recv().await + } + + pub(crate) fn writer_sender(&self) -> mpsc::Sender> { + self.writer_tx.clone() + } + + pub(crate) fn output_receiver(&self) -> Arc>>> { + self.output_rx.clone() + } +} diff --git a/codex-rs/core/src/exec_command/mod.rs b/codex-rs/core/src/exec_command/mod.rs new file mode 100644 index 0000000000..d48748f633 --- /dev/null +++ b/codex-rs/core/src/exec_command/mod.rs @@ -0,0 +1,13 @@ +mod exec_command_params; +mod exec_command_session; +mod responses_api; +mod session_id; +mod session_manager; + +pub use exec_command_params::ExecCommandParams; +pub use exec_command_params::WriteStdinParams; +pub use responses_api::EXEC_COMMAND_TOOL_NAME; +pub use responses_api::WRITE_STDIN_TOOL_NAME; +pub use responses_api::create_exec_command_tool_for_responses_api; +pub use responses_api::create_write_stdin_tool_for_responses_api; +pub use session_manager::SESSION_MANAGER; diff --git a/codex-rs/core/src/exec_command/responses_api.rs b/codex-rs/core/src/exec_command/responses_api.rs new file mode 100644 index 0000000000..c07684c326 --- /dev/null +++ b/codex-rs/core/src/exec_command/responses_api.rs @@ -0,0 +1,97 @@ +use std::collections::BTreeMap; + +use crate::openai_tools::JsonSchema; +use crate::openai_tools::ResponsesApiTool; + +pub const EXEC_COMMAND_TOOL_NAME: &str = "exec_command"; +pub const WRITE_STDIN_TOOL_NAME: &str = "write_stdin"; + +pub fn create_exec_command_tool_for_responses_api() -> ResponsesApiTool { + let mut properties = BTreeMap::::new(); + properties.insert( + "cmd".to_string(), + JsonSchema::String { + description: Some("The shell command to execute.".to_string()), + }, + ); + properties.insert( + "yield_time_ms".to_string(), + JsonSchema::Number { + description: Some("The maximum time in milliseconds to wait for output.".to_string()), + }, + ); + properties.insert( + "max_output_tokens".to_string(), + JsonSchema::Number { + description: Some("The maximum number of tokens to output.".to_string()), + }, + ); + properties.insert( + "shell".to_string(), + JsonSchema::String { + description: Some("The shell to use. Defaults to \"/bin/bash\".".to_string()), + }, + ); + properties.insert( + "login".to_string(), + JsonSchema::Boolean { + description: Some( + "Whether to run the command as a login shell. Defaults to true.".to_string(), + ), + }, + ); + + ResponsesApiTool { + name: EXEC_COMMAND_TOOL_NAME.to_owned(), + description: r#"Execute shell commands on the local machine with streaming output."# + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["cmd".to_string()]), + additional_properties: Some(false), + }, + } +} + +pub fn create_write_stdin_tool_for_responses_api() -> ResponsesApiTool { + let mut properties = BTreeMap::::new(); + properties.insert( + "session_id".to_string(), + JsonSchema::String { + description: Some("The ID of the exec_command session.".to_string()), + }, + ); + properties.insert( + "chars".to_string(), + JsonSchema::String { + description: Some("The characters to write to stdin.".to_string()), + }, + ); + properties.insert( + "yield_time_ms".to_string(), + JsonSchema::Number { + description: Some( + "The maximum time in milliseconds to wait for output after writing.".to_string(), + ), + }, + ); + properties.insert( + "max_output_tokens".to_string(), + JsonSchema::Number { + description: Some("The maximum number of tokens to output.".to_string()), + }, + ); + + ResponsesApiTool { + name: WRITE_STDIN_TOOL_NAME.to_owned(), + description: r#"Write characters to the stdin of an existing exec_command session."# + .to_string(), + strict: false, + parameters: JsonSchema::Object { + properties, + required: Some(vec!["session_id".to_string(), "chars".to_string()]), + additional_properties: Some(false), + }, + } +} diff --git a/codex-rs/core/src/exec_command/session_id.rs b/codex-rs/core/src/exec_command/session_id.rs new file mode 100644 index 0000000000..c97c5d5440 --- /dev/null +++ b/codex-rs/core/src/exec_command/session_id.rs @@ -0,0 +1,5 @@ +use serde::Deserialize; +use serde::Serialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct SessionId(pub u32); diff --git a/codex-rs/core/src/exec_command/session_manager.rs b/codex-rs/core/src/exec_command/session_manager.rs new file mode 100644 index 0000000000..60f0d284ad --- /dev/null +++ b/codex-rs/core/src/exec_command/session_manager.rs @@ -0,0 +1,302 @@ +use std::collections::HashMap; +use std::io::Read; +use std::sync::Arc; +use std::sync::LazyLock; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::AtomicU32; + +use portable_pty::CommandBuilder; +use portable_pty::PtySize; +use portable_pty::native_pty_system; +use serde_json::json; +use tokio::sync::Mutex; +use tokio::sync::mpsc; +use tokio::sync::oneshot; +use tokio::time::Duration; +use tokio::time::Instant; +use tokio::time::timeout; + +use crate::exec_command::exec_command_params::ExecCommandParams; +use crate::exec_command::exec_command_params::WriteStdinParams; +use crate::exec_command::exec_command_session::ExecCommandSession; +use crate::exec_command::session_id::SessionId; +use crate::models::FunctionCallOutputPayload; +use crate::models::ResponseInputItem; + +pub static SESSION_MANAGER: LazyLock = LazyLock::new(SessionManager::default); + +#[derive(Debug, Default)] +pub struct SessionManager { + next_session_id: AtomicU32, + sessions: Mutex>, +} + +impl SessionManager { + /// Processes the request and is required to send a response via `outgoing`. + pub async fn handle_exec_command_request( + &self, + call_id: String, + params: ExecCommandParams, + ) -> ResponseInputItem { + // Allocate a session id. + let session_id = SessionId( + self.next_session_id + .fetch_add(1, std::sync::atomic::Ordering::SeqCst), + ); + + let result = create_exec_command_session(session_id, params.clone()).await; + + match result { + Ok((session, mut exit_rx)) => { + // Insert into session map. + let output_receiver = session.output_receiver(); + self.sessions.lock().await.insert(session_id, session); + + // Collect output until either timeout expires or process exits. + // Cap by assuming 4 bytes per token (TODO: use a real tokenizer). + let cap_bytes_u64 = params.max_output_tokens.saturating_mul(4); + let cap_bytes: usize = cap_bytes_u64.min(usize::MAX as u64) as usize; + let cap_hint = cap_bytes.clamp(1024, 8192); + let mut collected: Vec = Vec::with_capacity(cap_hint); + + let deadline = Instant::now() + Duration::from_millis(params.yield_time_ms); + let mut exit_code: Option = None; + + loop { + if Instant::now() >= deadline { + break; + } + let remaining = deadline.saturating_duration_since(Instant::now()); + tokio::select! { + biased; + exit = &mut exit_rx => { + exit_code = exit.ok(); + // Small grace period to pull remaining buffered output + let grace_deadline = Instant::now() + Duration::from_millis(25); + while Instant::now() < grace_deadline { + let recv_next = async { + let mut rx = output_receiver.lock().await; + rx.recv().await + }; + if let Ok(Some(chunk)) = timeout(Duration::from_millis(1), recv_next).await { + let available = cap_bytes.saturating_sub(collected.len()); + if available == 0 { break; } + let take = available.min(chunk.len()); + collected.extend_from_slice(&chunk[..take]); + } else { + break; + } + } + break; + } + chunk = timeout(remaining, async { + let mut rx = output_receiver.lock().await; + rx.recv().await + }) => { + match chunk { + Ok(Some(chunk)) => { + let available = cap_bytes.saturating_sub(collected.len()); + if available == 0 { /* keep draining, but don't store */ } + else { + let take = available.min(chunk.len()); + collected.extend_from_slice(&chunk[..take]); + } + } + Ok(None) => { break; } + Err(_) => { break; } + } + } + } + } + + let text = String::from_utf8_lossy(&collected).to_string(); + let mut structured = json!({ "sessionId": session_id }); + if let Some(code) = exit_code { + structured["exitCode"] = json!(code); + } + + ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: text, + success: Some(true), + }, + } + } + Err(err) => ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("failed to start exec session: {err}"), + success: Some(false), + }, + }, + } + } + + /// Write characters to a session's stdin and collect combined output for up to `yield_time_ms`. + pub async fn handle_write_stdin_request( + &self, + call_id: String, + params: WriteStdinParams, + ) -> ResponseInputItem { + let WriteStdinParams { + session_id, + chars, + yield_time_ms, + max_output_tokens, + } = params; + + // Grab handles without holding the sessions lock across await points. + let (writer_tx, output_rx) = { + let sessions = self.sessions.lock().await; + match sessions.get(&session_id) { + Some(session) => (session.writer_sender(), session.output_receiver()), + None => { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: format!("unknown session id {}", session_id.0), + success: Some(false), + }, + }; + } + } + }; + + // Write stdin if provided. + if !chars.is_empty() && writer_tx.send(chars.into_bytes()).await.is_err() { + return ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: "failed to write to stdin".to_string(), + success: Some(false), + }, + }; + } + + // Collect output up to yield_time_ms, truncating to max_output_tokens bytes. + let mut collected: Vec = Vec::with_capacity(4096); + let deadline = Instant::now() + Duration::from_millis(yield_time_ms); + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + let remaining = deadline - now; + match timeout(remaining, output_rx.lock().await.recv()).await { + Ok(Some(chunk)) => { + // Respect token/byte limit; keep draining but drop once full. + let available = + max_output_tokens.saturating_sub(collected.len() as u64) as usize; + if available > 0 { + let take = available.min(chunk.len()); + collected.extend_from_slice(&chunk[..take]); + } + // Continue loop to drain further within time. + } + Ok(None) => break, // channel closed + Err(_) => break, // timeout + } + } + + // Return text output as a CallToolResult + let text = String::from_utf8_lossy(&collected).to_string(); + ResponseInputItem::FunctionCallOutput { + call_id, + output: FunctionCallOutputPayload { + content: text, + success: Some(true), + }, + } + } +} + +/// Spawn PTY and child process per spawn_exec_command_session logic. +async fn create_exec_command_session( + session_id: SessionId, + params: ExecCommandParams, +) -> anyhow::Result<(ExecCommandSession, oneshot::Receiver)> { + let ExecCommandParams { + cmd, + yield_time_ms: _, + max_output_tokens: _, + shell, + login, + } = params; + + // Use the native pty implementation for the system + let pty_system = native_pty_system(); + + // Create a new pty + let pair = pty_system.openpty(PtySize { + rows: 24, + cols: 80, + pixel_width: 0, + pixel_height: 0, + })?; + + // Spawn a shell into the pty + let mut command_builder = CommandBuilder::new(shell); + let shell_mode_opt = if login { "-lc" } else { "-c" }; + command_builder.arg(shell_mode_opt); + command_builder.arg(cmd); + + let mut child = pair.slave.spawn_command(command_builder)?; + + // Channel to forward write requests to the PTY writer. + let (writer_tx, mut writer_rx) = mpsc::channel::>(128); + // Channel for streaming PTY output to readers. + let (output_tx, output_rx) = mpsc::channel::>(256); + + // Reader task: drain PTY and forward chunks to output channel. + let mut reader = pair.master.try_clone_reader()?; + let output_tx_clone = output_tx.clone(); + tokio::task::spawn_blocking(move || { + let mut buf = [0u8; 8192]; + loop { + match reader.read(&mut buf) { + Ok(0) => break, // EOF + Ok(n) => { + // Forward; block if receiver is slow to avoid dropping output. + let _ = output_tx_clone.blocking_send(buf[..n].to_vec()); + } + Err(_) => break, + } + } + }); + + // Writer task: apply stdin writes to the PTY writer. + let writer = pair.master.take_writer()?; + let writer = Arc::new(StdMutex::new(writer)); + tokio::spawn({ + let writer = writer.clone(); + async move { + while let Some(bytes) = writer_rx.recv().await { + let writer = writer.clone(); + // Perform blocking write on a blocking thread. + let _ = tokio::task::spawn_blocking(move || { + if let Ok(mut guard) = writer.lock() { + use std::io::Write; + let _ = guard.write_all(&bytes); + let _ = guard.flush(); + } + }) + .await; + } + } + }); + + // Keep the child alive until it exits, then signal exit code. + let (exit_tx, exit_rx) = oneshot::channel::(); + tokio::task::spawn_blocking(move || { + let code = match child.wait() { + Ok(status) => status.exit_code() as i32, + Err(_) => -1, + }; + let _ = exit_tx.send(code); + }); + + // Create and store the session with channels. + let session = ExecCommandSession::new(session_id, writer_tx, output_rx); + Ok((session, exit_rx)) +} diff --git a/codex-rs/core/src/lib.rs b/codex-rs/core/src/lib.rs index 28d35f5376..ef066c1a0a 100644 --- a/codex-rs/core/src/lib.rs +++ b/codex-rs/core/src/lib.rs @@ -20,6 +20,7 @@ mod conversation_history; mod environment_context; pub mod error; pub mod exec; +mod exec_command; pub mod exec_env; mod flags; pub mod git_info; @@ -43,12 +44,13 @@ mod models; mod openai_model_info; mod openai_tools; pub mod plan_tool; -mod project_doc; +pub mod project_doc; mod rollout; pub(crate) mod safety; pub mod seatbelt; pub mod shell; pub mod spawn; +pub mod terminal; pub mod turn_diff_tracker; pub mod user_agent; mod user_notification; diff --git a/codex-rs/core/src/openai_tools.rs b/codex-rs/core/src/openai_tools.rs index dd5eb12516..95e21f7685 100644 --- a/codex-rs/core/src/openai_tools.rs +++ b/codex-rs/core/src/openai_tools.rs @@ -37,6 +37,7 @@ pub enum ConfigShellToolType { DefaultShell, ShellWithRequest { sandbox_policy: SandboxPolicy }, LocalShell, + StreamableShell, } #[derive(Debug, Clone)] @@ -53,8 +54,11 @@ impl ToolsConfig { sandbox_policy: SandboxPolicy, include_plan_tool: bool, include_apply_patch_tool: bool, + use_streamable_shell_tool: bool, ) -> Self { - let mut shell_type = if model_family.uses_local_shell_tool { + let mut shell_type = if use_streamable_shell_tool { + ConfigShellToolType::StreamableShell + } else if model_family.uses_local_shell_tool { ConfigShellToolType::LocalShell } else { ConfigShellToolType::DefaultShell @@ -533,6 +537,14 @@ pub(crate) fn get_openai_tools( ConfigShellToolType::LocalShell => { tools.push(OpenAiTool::LocalShell {}); } + ConfigShellToolType::StreamableShell => { + tools.push(OpenAiTool::Function( + crate::exec_command::create_exec_command_tool_for_responses_api(), + )); + tools.push(OpenAiTool::Function( + crate::exec_command::create_write_stdin_tool_for_responses_api(), + )); + } } if config.plan_tool { @@ -597,6 +609,7 @@ mod tests { SandboxPolicy::ReadOnly, true, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -612,6 +625,7 @@ mod tests { SandboxPolicy::ReadOnly, true, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools(&config, Some(HashMap::new())); @@ -627,6 +641,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( &config, @@ -721,6 +736,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( @@ -777,6 +793,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( @@ -828,6 +845,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( @@ -882,6 +900,7 @@ mod tests { SandboxPolicy::ReadOnly, false, model_family.uses_apply_patch_tool, + /*use_experimental_streamable_shell_tool*/ false, ); let tools = get_openai_tools( diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 3591b5b1d8..a869b40cb3 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -1,18 +1,19 @@ //! Project-level documentation discovery. //! -//! Project-level documentation can be stored in a file named `AGENTS.md`. -//! Currently, we include only the contents of the first file found as follows: +//! Project-level documentation can be stored in files named `AGENTS.md`. +//! We include the concatenation of all files found along the path from the +//! repository root to the current working directory as follows: //! -//! 1. Look for the doc file in the current working directory (as determined -//! by the `Config`). -//! 2. If not found, walk *upwards* until the Git repository root is reached -//! (detected by the presence of a `.git` directory/file), or failing that, -//! the filesystem root. -//! 3. If the Git root is encountered, look for the doc file there. If it -//! exists, the search stops – we do **not** walk past the Git root. +//! 1. Determine the Git repository root by walking upwards from the current +//! working directory until a `.git` directory or file is found. If no Git +//! root is found, only the current working directory is considered. +//! 2. Collect every `AGENTS.md` found from the repository root down to the +//! current working directory (inclusive) and concatenate their contents in +//! that order. +//! 3. We do **not** walk past the Git root. use crate::config::Config; -use std::path::Path; +use std::path::PathBuf; use tokio::io::AsyncReadExt; use tracing::error; @@ -26,7 +27,7 @@ const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n"; /// Combines `Config::instructions` and `AGENTS.md` (if present) into a single /// string of instructions. pub(crate) async fn get_user_instructions(config: &Config) -> Option { - match find_project_doc(config).await { + match read_project_docs(config).await { Ok(Some(project_doc)) => match &config.user_instructions { Some(original_instructions) => Some(format!( "{original_instructions}{PROJECT_DOC_SEPARATOR}{project_doc}" @@ -41,95 +42,135 @@ pub(crate) async fn get_user_instructions(config: &Config) -> Option { } } -/// Attempt to locate and load the project documentation. Currently, the search -/// starts from `Config::cwd`, but if we may want to consider other directories -/// in the future, e.g., additional writable directories in the `SandboxPolicy`. +/// Attempt to locate and load the project documentation. /// -/// On success returns `Ok(Some(contents))`. If no documentation file is found -/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as -/// `Err` so callers can decide how to handle them. -async fn find_project_doc(config: &Config) -> std::io::Result> { - let max_bytes = config.project_doc_max_bytes; +/// On success returns `Ok(Some(contents))` where `contents` is the +/// concatenation of all discovered docs. If no documentation file is found the +/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so +/// callers can decide how to handle them. +pub async fn read_project_docs(config: &Config) -> std::io::Result> { + let max_total = config.project_doc_max_bytes; - // Attempt to load from the working directory first. - if let Some(doc) = load_first_candidate(&config.cwd, CANDIDATE_FILENAMES, max_bytes).await? { - return Ok(Some(doc)); + if max_total == 0 { + return Ok(None); } - // Walk up towards the filesystem root, stopping once we encounter the Git - // repository root. The presence of **either** a `.git` *file* or - // *directory* counts. - let mut dir = config.cwd.clone(); + let paths = discover_project_doc_paths(config)?; + if paths.is_empty() { + return Ok(None); + } - // Canonicalize the path so that we do not end up in an infinite loop when - // `cwd` contains `..` components. + let mut remaining: u64 = max_total as u64; + let mut parts: Vec = Vec::new(); + + for p in paths { + if remaining == 0 { + break; + } + + let file = match tokio::fs::File::open(&p).await { + Ok(f) => f, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + }; + + let size = file.metadata().await?.len(); + let mut reader = tokio::io::BufReader::new(file).take(remaining); + let mut data: Vec = Vec::new(); + reader.read_to_end(&mut data).await?; + + if size > remaining { + tracing::warn!( + "Project doc `{}` exceeds remaining budget ({} bytes) - truncating.", + p.display(), + remaining, + ); + } + + let text = String::from_utf8_lossy(&data).to_string(); + if !text.trim().is_empty() { + parts.push(text); + remaining = remaining.saturating_sub(data.len() as u64); + } + } + + if parts.is_empty() { + Ok(None) + } else { + Ok(Some(parts.join("\n\n"))) + } +} + +/// Discover the list of AGENTS.md files using the same search rules as +/// `read_project_docs`, but return the file paths instead of concatenated +/// contents. The list is ordered from repository root to the current working +/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes` +/// is zero, returns an empty list. +pub fn discover_project_doc_paths(config: &Config) -> std::io::Result> { + let mut dir = config.cwd.clone(); if let Ok(canon) = dir.canonicalize() { dir = canon; } - while let Some(parent) = dir.parent() { - // `.git` can be a *file* (for worktrees or submodules) or a *dir*. - let git_marker = dir.join(".git"); - let git_exists = match tokio::fs::metadata(&git_marker).await { + // Build chain from cwd upwards and detect git root. + let mut chain: Vec = vec![dir.clone()]; + let mut git_root: Option = None; + let mut cursor = dir.clone(); + while let Some(parent) = cursor.parent() { + let git_marker = cursor.join(".git"); + let git_exists = match std::fs::metadata(&git_marker) { Ok(_) => true, Err(e) if e.kind() == std::io::ErrorKind::NotFound => false, Err(e) => return Err(e), }; if git_exists { - // We are at the repo root – attempt one final load. - if let Some(doc) = load_first_candidate(&dir, CANDIDATE_FILENAMES, max_bytes).await? { - return Ok(Some(doc)); - } + git_root = Some(cursor.clone()); break; } - dir = parent.to_path_buf(); + chain.push(parent.to_path_buf()); + cursor = parent.to_path_buf(); } - Ok(None) -} - -/// Attempt to load the first candidate file found in `dir`. Returns the file -/// contents (truncated if it exceeds `max_bytes`) when successful. -async fn load_first_candidate( - dir: &Path, - names: &[&str], - max_bytes: usize, -) -> std::io::Result> { - for name in names { - let candidate = dir.join(name); - - let file = match tokio::fs::File::open(&candidate).await { - Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, - Err(e) => return Err(e), - Ok(f) => f, - }; - - let size = file.metadata().await?.len(); - - let reader = tokio::io::BufReader::new(file); - let mut data = Vec::with_capacity(std::cmp::min(size as usize, max_bytes)); - let mut limited = reader.take(max_bytes as u64); - limited.read_to_end(&mut data).await?; - - if size as usize > max_bytes { - tracing::warn!( - "Project doc `{}` exceeds {max_bytes} bytes - truncating.", - candidate.display(), - ); + let search_dirs: Vec = if let Some(root) = git_root { + let mut dirs: Vec = Vec::new(); + let mut saw_root = false; + for p in chain.iter().rev() { + if !saw_root { + if p == &root { + saw_root = true; + } else { + continue; + } + } + dirs.push(p.clone()); } + dirs + } else { + vec![config.cwd.clone()] + }; - let contents = String::from_utf8_lossy(&data).to_string(); - if contents.trim().is_empty() { - // Empty file – treat as not found. - continue; + let mut found: Vec = Vec::new(); + for d in search_dirs { + for name in CANDIDATE_FILENAMES { + let candidate = d.join(name); + match std::fs::symlink_metadata(&candidate) { + Ok(md) => { + let ft = md.file_type(); + // Allow regular files and symlinks; opening will later fail for dangling links. + if ft.is_file() || ft.is_symlink() { + found.push(candidate); + break; + } + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue, + Err(e) => return Err(e), + } } - - return Ok(Some(contents)); } - Ok(None) + Ok(found) } #[cfg(test)] @@ -278,4 +319,32 @@ mod tests { assert_eq!(res, Some(INSTRUCTIONS.to_string())); } + + /// When both the repository root and the working directory contain + /// AGENTS.md files, their contents are concatenated from root to cwd. + #[tokio::test] + async fn concatenates_root_and_cwd_docs() { + let repo = tempfile::tempdir().expect("tempdir"); + + // Simulate a git repository. + std::fs::write( + repo.path().join(".git"), + "gitdir: /path/to/actual/git/dir\n", + ) + .unwrap(); + + // Repo root doc. + fs::write(repo.path().join("AGENTS.md"), "root doc").unwrap(); + + // Nested working directory with its own doc. + let nested = repo.path().join("workspace/crate_a"); + std::fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("AGENTS.md"), "crate doc").unwrap(); + + let mut cfg = make_config(&repo, 4096, None); + cfg.cwd = nested; + + let res = get_user_instructions(&cfg).await.expect("doc expected"); + assert_eq!(res, "root doc\n\ncrate doc"); + } } diff --git a/codex-rs/core/src/shell.rs b/codex-rs/core/src/shell.rs index c269b87ef2..3a69874eb2 100644 --- a/codex-rs/core/src/shell.rs +++ b/codex-rs/core/src/shell.rs @@ -1,14 +1,24 @@ +use serde::Deserialize; +use serde::Serialize; use shlex; +use std::path::PathBuf; -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub struct ZshShell { shell_path: String, zshrc_path: String, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] +pub struct PowerShellConfig { + exe: String, // Executable name or path, e.g. "pwsh" or "powershell.exe". + bash_exe_fallback: Option, // In case the model generates a bash command. +} + +#[derive(Debug, PartialEq, Eq, Clone, Serialize, Deserialize)] pub enum Shell { Zsh(ZshShell), + PowerShell(PowerShellConfig), Unknown, } @@ -33,6 +43,61 @@ impl Shell { } Some(result) } + Shell::PowerShell(ps) => { + // If model generated a bash command, prefer a detected bash fallback + if let Some(script) = strip_bash_lc(&command) { + return match &ps.bash_exe_fallback { + Some(bash) => Some(vec![ + bash.to_string_lossy().to_string(), + "-lc".to_string(), + script, + ]), + + // No bash fallback → run the script under PowerShell. + // It will likely fail (except for some simple commands), but the error + // should give a clue to the model to fix upon retry that it's running under PowerShell. + None => Some(vec![ + ps.exe.clone(), + "-NoProfile".to_string(), + "-Command".to_string(), + script, + ]), + }; + } + + // Not a bash command. If model did not generate a PowerShell command, + // turn it into a PowerShell command. + let first = command.first().map(String::as_str); + if first != Some(ps.exe.as_str()) { + // TODO (CODEX_2900): Handle escaping newlines. + if command.iter().any(|a| a.contains('\n') || a.contains('\r')) { + return Some(command); + } + + let joined = shlex::try_join(command.iter().map(|s| s.as_str())).ok(); + return joined.map(|arg| { + vec![ + ps.exe.clone(), + "-NoProfile".to_string(), + "-Command".to_string(), + arg, + ] + }); + } + + // Model generated a PowerShell command. Run it. + Some(command) + } + Shell::Unknown => None, + } + } + + pub fn name(&self) -> Option { + match self { + Shell::Zsh(zsh) => std::path::Path::new(&zsh.shell_path) + .file_name() + .map(|s| s.to_string_lossy().to_string()), + Shell::PowerShell(ps) => Some(ps.exe.clone()), Shell::Unknown => None, } } @@ -86,11 +151,51 @@ pub async fn default_user_shell() -> Shell { } } -#[cfg(not(target_os = "macos"))] +#[cfg(all(not(target_os = "macos"), not(target_os = "windows")))] pub async fn default_user_shell() -> Shell { Shell::Unknown } +#[cfg(target_os = "windows")] +pub async fn default_user_shell() -> Shell { + use tokio::process::Command; + + // Prefer PowerShell 7+ (`pwsh`) if available, otherwise fall back to Windows PowerShell. + let has_pwsh = Command::new("pwsh") + .arg("-NoLogo") + .arg("-NoProfile") + .arg("-Command") + .arg("$PSVersionTable.PSVersion.Major") + .output() + .await + .map(|o| o.status.success()) + .unwrap_or(false); + let bash_exe = if Command::new("bash.exe") + .arg("--version") + .output() + .await + .ok() + .map(|o| o.status.success()) + .unwrap_or(false) + { + which::which("bash.exe").ok() + } else { + None + }; + + if has_pwsh { + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: bash_exe, + }) + } else { + Shell::PowerShell(PowerShellConfig { + exe: "powershell.exe".to_string(), + bash_exe_fallback: bash_exe, + }) + } +} + #[cfg(test)] #[cfg(target_os = "macos")] mod tests { @@ -231,3 +336,97 @@ mod tests { } } } + +#[cfg(test)] +#[cfg(target_os = "windows")] +mod tests_windows { + use super::*; + + #[test] + fn test_format_default_shell_invocation_powershell() { + let cases = vec![ + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: None, + }), + vec!["bash", "-lc", "echo hello"], + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "powershell.exe".to_string(), + bash_exe_fallback: None, + }), + vec!["bash", "-lc", "echo hello"], + vec!["powershell.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec!["bash", "-lc", "echo hello"], + vec!["bash.exe", "-lc", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec![ + "bash", + "-lc", + "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: destination_file.txt\n-original content\n+modified content\n*** End Patch\nEOF", + ], + vec![ + "bash.exe", + "-lc", + "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: destination_file.txt\n-original content\n+modified content\n*** End Patch\nEOF", + ], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec!["echo", "hello"], + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + Shell::PowerShell(PowerShellConfig { + exe: "pwsh.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + vec!["pwsh.exe", "-NoProfile", "-Command", "echo hello"], + ), + ( + // TODO (CODEX_2900): Handle escaping newlines for powershell invocation. + Shell::PowerShell(PowerShellConfig { + exe: "powershell.exe".to_string(), + bash_exe_fallback: Some(PathBuf::from("bash.exe")), + }), + vec![ + "codex-mcp-server.exe", + "--codex-run-as-apply-patch", + "*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch", + ], + vec![ + "codex-mcp-server.exe", + "--codex-run-as-apply-patch", + "*** Begin Patch\n*** Update File: C:\\Users\\person\\destination_file.txt\n-original content\n+modified content\n*** End Patch", + ], + ), + ]; + + for (shell, input, expected_cmd) in cases { + let actual_cmd = shell + .format_default_shell_invocation(input.iter().map(|s| s.to_string()).collect()); + assert_eq!( + actual_cmd, + Some(expected_cmd.iter().map(|s| s.to_string()).collect()) + ); + } + } +} diff --git a/codex-rs/core/src/terminal.rs b/codex-rs/core/src/terminal.rs new file mode 100644 index 0000000000..02104f8be5 --- /dev/null +++ b/codex-rs/core/src/terminal.rs @@ -0,0 +1,72 @@ +use std::sync::OnceLock; + +static TERMINAL: OnceLock = OnceLock::new(); + +pub fn user_agent() -> String { + TERMINAL.get_or_init(detect_terminal).to_string() +} + +/// Sanitize a header value to be used in a User-Agent string. +/// +/// This function replaces any characters that are not allowed in a User-Agent string with an underscore. +/// +/// # Arguments +/// +/// * `value` - The value to sanitize. +fn is_valid_header_value_char(c: char) -> bool { + c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' || c == '/' +} + +fn sanitize_header_value(value: String) -> String { + value.replace(|c| !is_valid_header_value_char(c), "_") +} + +fn detect_terminal() -> String { + sanitize_header_value( + if let Ok(tp) = std::env::var("TERM_PROGRAM") + && !tp.trim().is_empty() + { + let ver = std::env::var("TERM_PROGRAM_VERSION").ok(); + match ver { + Some(v) if !v.trim().is_empty() => format!("{tp}/{v}"), + _ => tp, + } + } else if let Ok(v) = std::env::var("WEZTERM_VERSION") { + if !v.trim().is_empty() { + format!("WezTerm/{v}") + } else { + "WezTerm".to_string() + } + } else if std::env::var("KITTY_WINDOW_ID").is_ok() + || std::env::var("TERM") + .map(|t| t.contains("kitty")) + .unwrap_or(false) + { + "kitty".to_string() + } else if std::env::var("ALACRITTY_SOCKET").is_ok() + || std::env::var("TERM") + .map(|t| t == "alacritty") + .unwrap_or(false) + { + "Alacritty".to_string() + } else if let Ok(v) = std::env::var("KONSOLE_VERSION") { + if !v.trim().is_empty() { + format!("Konsole/{v}") + } else { + "Konsole".to_string() + } + } else if std::env::var("GNOME_TERMINAL_SCREEN").is_ok() { + return "gnome-terminal".to_string(); + } else if let Ok(v) = std::env::var("VTE_VERSION") { + if !v.trim().is_empty() { + format!("VTE/{v}") + } else { + "VTE".to_string() + } + } else if std::env::var("WT_SESSION").is_ok() { + return "WindowsTerminal".to_string(); + } else { + std::env::var("TERM").unwrap_or_else(|_| "unknown".to_string()) + }, + ) +} diff --git a/codex-rs/core/src/user_agent.rs b/codex-rs/core/src/user_agent.rs index ddcfd4b7f1..a63170cebd 100644 --- a/codex-rs/core/src/user_agent.rs +++ b/codex-rs/core/src/user_agent.rs @@ -4,11 +4,12 @@ pub fn get_codex_user_agent(originator: Option<&str>) -> String { let build_version = env!("CARGO_PKG_VERSION"); let os_info = os_info::get(); format!( - "{}/{build_version} ({} {}; {})", + "{}/{build_version} ({} {}; {}) {}", originator.unwrap_or(DEFAULT_ORIGINATOR), os_info.os_type(), os_info.version(), os_info.architecture().unwrap_or("unknown"), + crate::terminal::user_agent() ) } @@ -27,9 +28,10 @@ mod tests { fn test_macos() { use regex_lite::Regex; let user_agent = get_codex_user_agent(None); - let re = - Regex::new(r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\)$") - .unwrap(); + let re = Regex::new( + r"^codex_cli_rs/\d+\.\d+\.\d+ \(Mac OS \d+\.\d+\.\d+; (x86_64|arm64)\) (\S+)$", + ) + .unwrap(); assert!(re.is_match(&user_agent)); } } diff --git a/codex-rs/core/tests/prompt_caching.rs b/codex-rs/core/tests/prompt_caching.rs index 9f5829e113..bf49262a88 100644 --- a/codex-rs/core/tests/prompt_caching.rs +++ b/codex-rs/core/tests/prompt_caching.rs @@ -8,6 +8,7 @@ use codex_core::protocol::Op; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; use codex_core::protocol_config_types::ReasoningSummary; +use codex_core::shell::default_user_shell; use codex_login::CodexAuth; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; @@ -85,9 +86,20 @@ async fn prefixes_context_and_instructions_once_and_consistently_across_requests let requests = server.received_requests().await.unwrap(); assert_eq!(requests.len(), 2, "expected two POST requests"); + let shell = default_user_shell().await; + let expected_env_text = format!( - "\nCurrent working directory: {}\nApproval policy: on-request\nSandbox mode: read-only\nNetwork access: restricted\n", - cwd.path().to_string_lossy() + r#" + {} + on-request + read-only + restricted +{}"#, + cwd.path().to_string_lossy(), + match shell.name() { + Some(name) => format!(" {}\n", name), + None => String::new(), + } ); let expected_ui_text = "\n\nbe consistent and helpful\n\n"; @@ -183,12 +195,10 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { .unwrap(); wait_for_event(&codex, |ev| matches!(ev, EventMsg::TaskComplete(_))).await; - // Change everything about the turn context. - let new_cwd = TempDir::new().unwrap(); let writable = TempDir::new().unwrap(); codex .submit(Op::OverrideTurnContext { - cwd: Some(new_cwd.path().to_path_buf()), + cwd: None, approval_policy: Some(AskForApproval::Never), sandbox_policy: Some(SandboxPolicy::WorkspaceWrite { writable_roots: vec![writable.path().to_path_buf()], @@ -220,7 +230,6 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { let body1 = requests[0].body_json::().unwrap(); let body2 = requests[1].body_json::().unwrap(); - // prompt_cache_key should remain constant across overrides assert_eq!( body1["prompt_cache_key"], body2["prompt_cache_key"], @@ -236,11 +245,13 @@ async fn overrides_turn_context_but_keeps_cached_prefix_and_key_constant() { "content": [ { "type": "input_text", "text": "hello 2" } ] }); // After overriding the turn context, the environment context should be emitted again - // reflecting the new cwd, approval policy and sandbox settings. - let expected_env_text_2 = format!( - "\nCurrent working directory: {}\nApproval policy: never\nSandbox mode: workspace-write\nNetwork access: enabled\n", - new_cwd.path().to_string_lossy() - ); + // reflecting the new approval policy and sandbox settings. Omit cwd because it did + // not change. + let expected_env_text_2 = r#" + never + workspace-write + enabled +"#; let expected_env_msg_2 = serde_json::json!({ "type": "message", "id": serde_json::Value::Null, diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index 498b1d29a4..9a562cbd4d 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -20,6 +20,7 @@ use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; use codex_core::protocol::SessionConfiguredEvent; +use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TurnAbortReason; use codex_core::protocol::TurnDiffEvent; @@ -174,6 +175,9 @@ impl EventProcessor for EventProcessorWithHumanOutput { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { ts_println!(self, "{}", message.style(self.dimmed)); } + EventMsg::StreamError(StreamErrorEvent { message }) => { + ts_println!(self, "{}", message.style(self.dimmed)); + } EventMsg::TaskStarted => { // Ignore. } diff --git a/codex-rs/justfile b/codex-rs/justfile index 3e1336be43..beaa682daa 100644 --- a/codex-rs/justfile +++ b/codex-rs/justfile @@ -24,8 +24,8 @@ file-search *args: fmt: cargo fmt -- --config imports_granularity=Item -fix: - cargo clippy --fix --all-features --tests --allow-dirty +fix *args: + cargo clippy --fix --all-features --tests --allow-dirty "$@" install: rustup show active-toolchain diff --git a/codex-rs/login/Cargo.toml b/codex-rs/login/Cargo.toml index c1e21ca627..bf04a8e3ff 100644 --- a/codex-rs/login/Cargo.toml +++ b/codex-rs/login/Cargo.toml @@ -9,6 +9,7 @@ workspace = true [dependencies] base64 = "0.22" chrono = { version = "0.4", features = ["serde"] } +codex-protocol = { path = "../protocol" } rand = "0.8" reqwest = { version = "0.12", features = ["json", "blocking"] } serde = { version = "1", features = ["derive"] } diff --git a/codex-rs/login/src/lib.rs b/codex-rs/login/src/lib.rs index d6fa4aef9a..1f11882302 100644 --- a/codex-rs/login/src/lib.rs +++ b/codex-rs/login/src/lib.rs @@ -29,13 +29,7 @@ mod token_data; pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; pub const OPENAI_API_KEY_ENV_VAR: &str = "OPENAI_API_KEY"; - -#[derive(Clone, Debug, PartialEq, Copy, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum AuthMode { - ApiKey, - ChatGPT, -} +pub use codex_protocol::mcp_protocol::AuthMode; #[derive(Debug, Clone)] pub struct CodexAuth { @@ -242,7 +236,7 @@ fn load_auth( // "refreshable" even if we are using the API key for auth? match &tokens { Some(tokens) => { - if tokens.should_use_api_key(preferred_auth_method) { + if tokens.should_use_api_key(preferred_auth_method, tokens.is_openai_email()) { return Ok(Some(CodexAuth::from_api_key(api_key))); } else { // Ignore the API key and fall through to ChatGPT auth. diff --git a/codex-rs/login/src/token_data.rs b/codex-rs/login/src/token_data.rs index 59e6d97269..f6d04f16a6 100644 --- a/codex-rs/login/src/token_data.rs +++ b/codex-rs/login/src/token_data.rs @@ -25,16 +25,31 @@ pub struct TokenData { impl TokenData { /// Returns true if this is a plan that should use the traditional /// "metered" billing via an API key. - pub(crate) fn should_use_api_key(&self, preferred_auth_method: AuthMode) -> bool { + pub(crate) fn should_use_api_key( + &self, + preferred_auth_method: AuthMode, + is_openai_email: bool, + ) -> bool { if preferred_auth_method == AuthMode::ApiKey { return true; } + // If the email is an OpenAI email, use AuthMode::ChatGPT unless preferred_auth_method is AuthMode::ApiKey. + if is_openai_email { + return false; + } self.id_token .chatgpt_plan_type .as_ref() .is_none_or(|plan| plan.is_plan_that_should_use_api_key()) } + + pub fn is_openai_email(&self) -> bool { + self.id_token + .email + .as_deref() + .is_some_and(|email| email.trim().to_ascii_lowercase().ends_with("@openai.com")) + } } /// Flat subset of useful claims in id_token from auth.json. diff --git a/codex-rs/mcp-server/Cargo.toml b/codex-rs/mcp-server/Cargo.toml index 44a3907159..cddf4cf3e4 100644 --- a/codex-rs/mcp-server/Cargo.toml +++ b/codex-rs/mcp-server/Cargo.toml @@ -17,7 +17,7 @@ workspace = true [dependencies] anyhow = "1" codex-arg0 = { path = "../arg0" } -codex-common = { path = "../common" } +codex-common = { path = "../common", features = ["cli"] } codex-core = { path = "../core" } codex-login = { path = "../login" } codex-protocol = { path = "../protocol" } diff --git a/codex-rs/mcp-server/src/codex_message_processor.rs b/codex-rs/mcp-server/src/codex_message_processor.rs index 07e06d66eb..657cda25ce 100644 --- a/codex-rs/mcp-server/src/codex_message_processor.rs +++ b/codex-rs/mcp-server/src/codex_message_processor.rs @@ -14,6 +14,7 @@ use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; use codex_core::protocol::ReviewDecision; +use codex_protocol::mcp_protocol::AuthMode; use codex_protocol::mcp_protocol::GitDiffToRemoteResponse; use mcp_types::JSONRPCErrorError; use mcp_types::RequestId; @@ -30,14 +31,17 @@ use crate::outgoing_message::OutgoingNotification; use codex_core::protocol::InputItem as CoreInputItem; use codex_core::protocol::Op; use codex_login::CLIENT_ID; +use codex_login::CodexAuth; use codex_login::ServerOptions as LoginServerOptions; use codex_login::ShutdownHandle; +use codex_login::logout; use codex_login::run_login_server; use codex_protocol::mcp_protocol::APPLY_PATCH_APPROVAL_METHOD; use codex_protocol::mcp_protocol::AddConversationListenerParams; use codex_protocol::mcp_protocol::AddConversationSubscriptionResponse; use codex_protocol::mcp_protocol::ApplyPatchApprovalParams; use codex_protocol::mcp_protocol::ApplyPatchApprovalResponse; +use codex_protocol::mcp_protocol::AuthStatusChangeNotification; use codex_protocol::mcp_protocol::ClientRequest; use codex_protocol::mcp_protocol::ConversationId; use codex_protocol::mcp_protocol::EXEC_COMMAND_APPROVAL_METHOD; @@ -46,7 +50,6 @@ use codex_protocol::mcp_protocol::ExecCommandApprovalResponse; use codex_protocol::mcp_protocol::InputItem as WireInputItem; use codex_protocol::mcp_protocol::InterruptConversationParams; use codex_protocol::mcp_protocol::InterruptConversationResponse; -use codex_protocol::mcp_protocol::LOGIN_CHATGPT_COMPLETE_EVENT; use codex_protocol::mcp_protocol::LoginChatGptCompleteNotification; use codex_protocol::mcp_protocol::LoginChatGptResponse; use codex_protocol::mcp_protocol::NewConversationParams; @@ -57,6 +60,7 @@ use codex_protocol::mcp_protocol::SendUserMessageParams; use codex_protocol::mcp_protocol::SendUserMessageResponse; use codex_protocol::mcp_protocol::SendUserTurnParams; use codex_protocol::mcp_protocol::SendUserTurnResponse; +use codex_protocol::mcp_protocol::ServerNotification; // Duration before a ChatGPT login attempt is abandoned. const LOGIN_CHATGPT_TIMEOUT: Duration = Duration::from_secs(10 * 60); @@ -77,6 +81,7 @@ pub(crate) struct CodexMessageProcessor { conversation_manager: Arc, outgoing: Arc, codex_linux_sandbox_exe: Option, + config: Arc, conversation_listeners: HashMap>, active_login: Arc>>, // Queue of pending interrupt requests per conversation. We reply when TurnAborted arrives. @@ -88,11 +93,13 @@ impl CodexMessageProcessor { conversation_manager: Arc, outgoing: Arc, codex_linux_sandbox_exe: Option, + config: Arc, ) -> Self { Self { conversation_manager, outgoing, codex_linux_sandbox_exe, + config, conversation_listeners: HashMap::new(), active_login: Arc::new(Mutex::new(None)), pending_interrupts: Arc::new(Mutex::new(HashMap::new())), @@ -128,6 +135,12 @@ impl CodexMessageProcessor { ClientRequest::CancelLoginChatGpt { request_id, params } => { self.cancel_login_chatgpt(request_id, params.login_id).await; } + ClientRequest::LogoutChatGpt { request_id } => { + self.logout_chatgpt(request_id).await; + } + ClientRequest::GetAuthStatus { request_id } => { + self.get_auth_status(request_id).await; + } ClientRequest::GitDiffToRemote { request_id, params } => { self.git_diff_to_origin(request_id, params.cwd).await; } @@ -135,19 +148,7 @@ impl CodexMessageProcessor { } async fn login_chatgpt(&mut self, request_id: RequestId) { - let config = - match Config::load_with_cli_overrides(Default::default(), ConfigOverrides::default()) { - Ok(cfg) => cfg, - Err(err) => { - let error = JSONRPCErrorError { - code: INTERNAL_ERROR_CODE, - message: format!("error loading config for login: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - }; + let config = self.config.as_ref(); let opts = LoginServerOptions { open_browser: false, @@ -199,19 +200,25 @@ impl CodexMessageProcessor { (false, Some("Login timed out".to_string())) } }; - let notification = LoginChatGptCompleteNotification { + let payload = LoginChatGptCompleteNotification { login_id, success, error: error_msg, }; - let params = serde_json::to_value(¬ification).ok(); outgoing_clone - .send_notification(OutgoingNotification { - method: LOGIN_CHATGPT_COMPLETE_EVENT.to_string(), - params, - }) + .send_server_notification(ServerNotification::LoginChatGptComplete(payload)) .await; + // Send an auth status change notification. + if success { + let payload = AuthStatusChangeNotification { + auth_method: Some(AuthMode::ChatGPT), + }; + outgoing_clone + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + // Clear the active login if it matches this attempt. It may have been replaced or cancelled. let mut guard = active_login.lock().await; if guard.as_ref().map(|l| l.login_id) == Some(login_id) { @@ -260,6 +267,78 @@ impl CodexMessageProcessor { } } + async fn logout_chatgpt(&mut self, request_id: RequestId) { + { + // Cancel any active login attempt. + let mut guard = self.active_login.lock().await; + if let Some(active) = guard.take() { + active.drop(); + } + } + + // Load config to locate codex_home for persistent logout. + let config = self.config.as_ref(); + + if let Err(err) = logout(&config.codex_home) { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("logout failed: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + + self.outgoing + .send_response( + request_id, + codex_protocol::mcp_protocol::LogoutChatGptResponse {}, + ) + .await; + + // Send auth status change notification. + let payload = AuthStatusChangeNotification { auth_method: None }; + self.outgoing + .send_server_notification(ServerNotification::AuthStatusChange(payload)) + .await; + } + + async fn get_auth_status(&self, request_id: RequestId) { + // Load config to determine codex_home and preferred auth method. + let config = self.config.as_ref(); + + let preferred_auth_method: AuthMode = config.preferred_auth_method; + let response = + match CodexAuth::from_codex_home(&config.codex_home, config.preferred_auth_method) { + Ok(Some(auth)) => { + // Verify that the current auth mode has a valid, non-empty token. + // If token acquisition fails or is empty, treat as unauthenticated. + let reported_auth_method = match auth.get_token().await { + Ok(token) if !token.is_empty() => Some(auth.mode), + Ok(_) => None, // Empty token + Err(err) => { + tracing::warn!("failed to get token for auth status: {err}"); + None + } + }; + codex_protocol::mcp_protocol::GetAuthStatusResponse { + auth_method: reported_auth_method, + preferred_auth_method, + } + } + Ok(None) => codex_protocol::mcp_protocol::GetAuthStatusResponse { + auth_method: None, + preferred_auth_method, + }, + Err(_) => codex_protocol::mcp_protocol::GetAuthStatusResponse { + auth_method: None, + preferred_auth_method, + }, + }; + + self.outgoing.send_response(request_id, response).await; + } + async fn process_new_conversation(&self, request_id: RequestId, params: NewConversationParams) { let config = match derive_config_from_params(params, self.codex_linux_sandbox_exe.clone()) { Ok(config) => config, diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index e1dfd08c8e..36845d895c 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -268,6 +268,7 @@ async fn run_codex_tool_session_inner( | EventMsg::ExecCommandOutputDelta(_) | EventMsg::ExecCommandEnd(_) | EventMsg::BackgroundEvent(_) + | EventMsg::StreamError(_) | EventMsg::PatchApplyBegin(_) | EventMsg::PatchApplyEnd(_) | EventMsg::TurnDiff(_) diff --git a/codex-rs/mcp-server/src/lib.rs b/codex-rs/mcp-server/src/lib.rs index e22df1f9ef..aaf3e31441 100644 --- a/codex-rs/mcp-server/src/lib.rs +++ b/codex-rs/mcp-server/src/lib.rs @@ -1,9 +1,14 @@ //! Prototype MCP server. #![deny(clippy::print_stdout, clippy::print_stderr)] +use std::io::ErrorKind; use std::io::Result as IoResult; use std::path::PathBuf; +use codex_common::CliConfigOverrides; +use codex_core::config::Config; +use codex_core::config::ConfigOverrides; + use mcp_types::JSONRPCMessage; use tokio::io::AsyncBufReadExt; use tokio::io::AsyncWriteExt; @@ -41,7 +46,10 @@ pub use crate::patch_approval::PatchApprovalResponse; /// plenty for an interactive CLI. const CHANNEL_CAPACITY: usize = 128; -pub async fn run_main(codex_linux_sandbox_exe: Option) -> IoResult<()> { +pub async fn run_main( + codex_linux_sandbox_exe: Option, + cli_config_overrides: CliConfigOverrides, +) -> IoResult<()> { // Install a simple subscriber so `tracing` output is visible. Users can // control the log level with `RUST_LOG`. tracing_subscriber::fmt() @@ -77,10 +85,27 @@ pub async fn run_main(codex_linux_sandbox_exe: Option) -> IoResult<()> } }); + // Parse CLI overrides once and derive the base Config eagerly so later + // components do not need to work with raw TOML values. + let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| { + std::io::Error::new( + ErrorKind::InvalidInput, + format!("error parsing -c overrides: {e}"), + ) + })?; + let config = Config::load_with_cli_overrides(cli_kv_overrides, ConfigOverrides::default()) + .map_err(|e| { + std::io::Error::new(ErrorKind::InvalidData, format!("error loading config: {e}")) + })?; + // Task: process incoming messages. let processor_handle = tokio::spawn({ let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx); - let mut processor = MessageProcessor::new(outgoing_message_sender, codex_linux_sandbox_exe); + let mut processor = MessageProcessor::new( + outgoing_message_sender, + codex_linux_sandbox_exe, + std::sync::Arc::new(config), + ); async move { while let Some(msg) = incoming_rx.recv().await { match msg { diff --git a/codex-rs/mcp-server/src/main.rs b/codex-rs/mcp-server/src/main.rs index 60ddeeab41..314944fab5 100644 --- a/codex-rs/mcp-server/src/main.rs +++ b/codex-rs/mcp-server/src/main.rs @@ -1,9 +1,10 @@ use codex_arg0::arg0_dispatch_or_else; +use codex_common::CliConfigOverrides; use codex_mcp_server::run_main; fn main() -> anyhow::Result<()> { arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { - run_main(codex_linux_sandbox_exe).await?; + run_main(codex_linux_sandbox_exe, CliConfigOverrides::default()).await?; Ok(()) }) } diff --git a/codex-rs/mcp-server/src/message_processor.rs b/codex-rs/mcp-server/src/message_processor.rs index 1ddcc6bc28..a22f9c5b4e 100644 --- a/codex-rs/mcp-server/src/message_processor.rs +++ b/codex-rs/mcp-server/src/message_processor.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::path::PathBuf; -use std::sync::Arc; use crate::codex_message_processor::CodexMessageProcessor; use crate::codex_tool_config::CodexToolCallParam; @@ -12,7 +11,7 @@ use crate::outgoing_message::OutgoingMessageSender; use codex_protocol::mcp_protocol::ClientRequest; use codex_core::ConversationManager; -use codex_core::config::Config as CodexConfig; +use codex_core::config::Config; use codex_core::protocol::Submission; use mcp_types::CallToolRequestParams; use mcp_types::CallToolResult; @@ -30,6 +29,7 @@ use mcp_types::ServerCapabilitiesTools; use mcp_types::ServerNotification; use mcp_types::TextContent; use serde_json::json; +use std::sync::Arc; use tokio::sync::Mutex; use tokio::task; use uuid::Uuid; @@ -49,6 +49,7 @@ impl MessageProcessor { pub(crate) fn new( outgoing: OutgoingMessageSender, codex_linux_sandbox_exe: Option, + config: Arc, ) -> Self { let outgoing = Arc::new(outgoing); let conversation_manager = Arc::new(ConversationManager::default()); @@ -56,6 +57,7 @@ impl MessageProcessor { conversation_manager.clone(), outgoing.clone(), codex_linux_sandbox_exe.clone(), + config, ); Self { codex_message_processor, @@ -344,7 +346,7 @@ impl MessageProcessor { } } async fn handle_tool_call_codex(&self, id: RequestId, arguments: Option) { - let (initial_prompt, config): (String, CodexConfig) = match arguments { + let (initial_prompt, config): (String, Config) = match arguments { Some(json_val) => match serde_json::from_value::(json_val) { Ok(tool_cfg) => match tool_cfg.into_config(self.codex_linux_sandbox_exe.clone()) { Ok(cfg) => cfg, diff --git a/codex-rs/mcp-server/src/outgoing_message.rs b/codex-rs/mcp-server/src/outgoing_message.rs index c5e51a3494..16241a0899 100644 --- a/codex-rs/mcp-server/src/outgoing_message.rs +++ b/codex-rs/mcp-server/src/outgoing_message.rs @@ -3,6 +3,7 @@ use std::sync::atomic::AtomicI64; use std::sync::atomic::Ordering; use codex_core::protocol::Event; +use codex_protocol::mcp_protocol::ServerNotification; use mcp_types::JSONRPC_VERSION; use mcp_types::JSONRPCError; use mcp_types::JSONRPCErrorError; @@ -121,6 +122,17 @@ impl OutgoingMessageSender { .await; } + pub(crate) async fn send_server_notification(&self, notification: ServerNotification) { + let method = format!("codex/event/{}", notification); + let params = match serde_json::to_value(¬ification) { + Ok(serde_json::Value::Object(mut map)) => map.remove("data"), + _ => None, + }; + let outgoing_message = + OutgoingMessage::Notification(OutgoingNotification { method, params }); + let _ = self.sender.send(outgoing_message).await; + } + pub(crate) async fn send_notification(&self, notification: OutgoingNotification) { let outgoing_message = OutgoingMessage::Notification(notification); let _ = self.sender.send(outgoing_message).await; diff --git a/codex-rs/protocol-ts/src/lib.rs b/codex-rs/protocol-ts/src/lib.rs index 6bbc926988..2366ae862d 100644 --- a/codex-rs/protocol-ts/src/lib.rs +++ b/codex-rs/protocol-ts/src/lib.rs @@ -41,6 +41,7 @@ pub fn generate_ts(out_dir: &Path, prettier: Option<&Path>) -> Result<()> { codex_protocol::mcp_protocol::ApplyPatchApprovalResponse::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalParams::export_all_to(out_dir)?; codex_protocol::mcp_protocol::ExecCommandApprovalResponse::export_all_to(out_dir)?; + codex_protocol::mcp_protocol::ServerNotification::export_all_to(out_dir)?; // Prepend header to each generated .ts file let ts_files = ts_files_in(out_dir)?; diff --git a/codex-rs/protocol/src/mcp_protocol.rs b/codex-rs/protocol/src/mcp_protocol.rs index 68f5c01d07..7cb38e15e2 100644 --- a/codex-rs/protocol/src/mcp_protocol.rs +++ b/codex-rs/protocol/src/mcp_protocol.rs @@ -13,6 +13,7 @@ use crate::protocol::TurnAbortReason; use mcp_types::RequestId; use serde::Deserialize; use serde::Serialize; +use strum_macros::Display; use ts_rs::TS; use uuid::Uuid; @@ -36,6 +37,13 @@ impl GitSha { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, TS)] +#[serde(rename_all = "lowercase")] +pub enum AuthMode { + ApiKey, + ChatGPT, +} + /// Request from the client to the server. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(tag = "method", rename_all = "camelCase")] @@ -79,6 +87,14 @@ pub enum ClientRequest { request_id: RequestId, params: CancelLoginChatGptParams, }, + LogoutChatGpt { + #[serde(rename = "id")] + request_id: RequestId, + }, + GetAuthStatus { + #[serde(rename = "id")] + request_id: RequestId, + }, GitDiffToRemote { #[serde(rename = "id")] request_id: RequestId, @@ -161,18 +177,6 @@ pub struct GitDiffToRemoteResponse { pub diff: String, } -// Event name for notifying client of login completion or failure. -pub const LOGIN_CHATGPT_COMPLETE_EVENT: &str = "codex/event/login_chatgpt_complete"; - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] -#[serde(rename_all = "camelCase")] -pub struct LoginChatGptCompleteNotification { - pub login_id: Uuid, - pub success: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptParams { @@ -189,6 +193,30 @@ pub struct GitDiffToRemoteParams { #[serde(rename_all = "camelCase")] pub struct CancelLoginChatGptResponse {} +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LogoutChatGptParams { + pub login_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LogoutChatGptResponse {} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthStatusParams { + pub login_id: Uuid, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct GetAuthStatusResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_method: Option, + pub preferred_auth_method: AuthMode, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] #[serde(rename_all = "camelCase")] pub struct SendUserMessageParams { @@ -321,6 +349,34 @@ pub struct ApplyPatchApprovalResponse { pub decision: ReviewDecision, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct LoginChatGptCompleteNotification { + pub login_id: Uuid, + pub success: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS)] +#[serde(rename_all = "camelCase")] +pub struct AuthStatusChangeNotification { + /// Current authentication method; omitted if signed out. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth_method: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, TS, Display)] +#[serde(tag = "type", content = "data", rename_all = "snake_case")] +#[strum(serialize_all = "snake_case")] +pub enum ServerNotification { + /// Authentication status changed + AuthStatusChange(AuthStatusChangeNotification), + + /// ChatGPT login flow completed + LoginChatGptComplete(LoginChatGptCompleteNotification), +} + #[cfg(test)] mod tests { use super::*; diff --git a/codex-rs/protocol/src/parse_command.rs b/codex-rs/protocol/src/parse_command.rs index 495562a765..63399acc65 100644 --- a/codex-rs/protocol/src/parse_command.rs +++ b/codex-rs/protocol/src/parse_command.rs @@ -2,6 +2,7 @@ use serde::Deserialize; use serde::Serialize; #[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] pub enum ParsedCommand { Read { cmd: String, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 65ef30aa6b..fbe052bf95 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -446,6 +446,10 @@ pub enum EventMsg { BackgroundEvent(BackgroundEventEvent), + /// Notification that a model stream experienced an error or disconnect + /// and the system is handling it (e.g., retrying with backoff). + StreamError(StreamErrorEvent), + /// Notification that the agent is about to apply a code patch. Mirrors /// `ExecCommandBegin` so front‑ends can show progress indicators. PatchApplyBegin(PatchApplyBeginEvent), @@ -721,6 +725,11 @@ pub struct BackgroundEventEvent { pub message: String, } +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StreamErrorEvent { + pub message: String, +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct PatchApplyBeginEvent { /// Identifier so this can be paired with the PatchApplyEnd event. diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index 9e0e31e172..20ceb0b79c 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -22,6 +22,7 @@ workspace = true [dependencies] anyhow = "1" +async-stream = "0.3.6" base64 = "0.22.1" chrono = { version = "0.4", features = ["serde"] } clap = { version = "4", features = ["derive"] } @@ -38,7 +39,7 @@ codex-login = { path = "../login" } codex-ollama = { path = "../ollama" } codex-protocol = { path = "../protocol" } color-eyre = "0.6.3" -crossterm = { version = "0.28.1", features = ["bracketed-paste"] } +crossterm = { version = "0.28.1", features = ["bracketed-paste", "event-stream"] } diffy = "0.4.2" image = { version = "^0.25.6", default-features = false, features = ["jpeg"] } lazy_static = "1" @@ -68,6 +69,7 @@ tokio = { version = "1", features = [ "rt-multi-thread", "signal", ] } +tokio-stream = "0.1.17" tracing = { version = "0.1.41", features = ["log"] } tracing-appender = "0.2.3" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index c7a1693617..e706fa723b 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -1,735 +1,318 @@ -use crate::LoginStatus; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::chatwidget::ChatWidget; use crate::file_search::FileSearchManager; -use crate::get_git_diff::get_git_diff; -use crate::get_login_status; -use crate::onboarding::onboarding_screen::KeyboardHandler; -use crate::onboarding::onboarding_screen::OnboardingScreen; -use crate::onboarding::onboarding_screen::OnboardingScreenArgs; -use crate::slash_command::SlashCommand; +use crate::transcript_app::TranscriptApp; use crate::tui; +use crate::tui::TuiEvent; use codex_core::ConversationManager; use codex_core::config::Config; -use codex_core::protocol::Event; -use codex_core::protocol::Op; +use codex_core::protocol::TokenUsage; use color_eyre::eyre::Result; -use crossterm::SynchronizedUpdate; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; +use crossterm::execute; +use crossterm::terminal::EnterAlternateScreen; +use crossterm::terminal::LeaveAlternateScreen; use crossterm::terminal::supports_keyboard_enhancement; -use ratatui::layout::Offset; -use ratatui::prelude::Backend; +use ratatui::layout::Rect; use ratatui::text::Line; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; use std::sync::atomic::Ordering; -use std::sync::mpsc::Receiver; -use std::sync::mpsc::channel; use std::thread; use std::time::Duration; -use std::time::Instant; +use tokio::select; +use tokio::sync::mpsc::unbounded_channel; -/// Time window for debouncing redraw requests. -const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1); - -/// Top-level application state: which full-screen view is currently active. -#[allow(clippy::large_enum_variant)] -enum AppState<'a> { - Onboarding { - screen: OnboardingScreen, - }, - /// The main chat UI is visible. - Chat { - /// Boxed to avoid a large enum variant and reduce the overall size of - /// `AppState`. - widget: Box>, - }, -} - -pub(crate) struct App<'a> { +pub(crate) struct App { server: Arc, app_event_tx: AppEventSender, - app_event_rx: Receiver, - app_state: AppState<'a>, + chat_widget: ChatWidget, /// Config is stored here so we can recreate ChatWidgets as needed. config: Config, file_search: FileSearchManager, - pending_history_lines: Vec>, + transcript_lines: Vec>, + + // Transcript overlay state + transcript_overlay: Option, + deferred_history_lines: Vec>, + transcript_saved_viewport: Option, enhanced_keys_supported: bool, /// Controls the animation thread that sends CommitTick events. commit_anim_running: Arc, - - /// Channel to schedule one-shot animation frames; coalesced by a single - /// scheduler thread. - frame_schedule_tx: std::sync::mpsc::Sender, } -/// Aggregate parameters needed to create a `ChatWidget`, as creation may be -/// deferred until after the Git warning screen is dismissed. -#[derive(Clone, Debug)] -pub(crate) struct ChatWidgetArgs { - pub(crate) config: Config, - initial_prompt: Option, - initial_images: Vec, - enhanced_keys_supported: bool, -} - -impl App<'_> { - pub(crate) fn new( +impl App { + pub async fn run( + tui: &mut tui::Tui, config: Config, initial_prompt: Option, - initial_images: Vec, - show_trust_screen: bool, - ) -> Self { - let conversation_manager = Arc::new(ConversationManager::default()); - - let (app_event_tx, app_event_rx) = channel(); + initial_images: Vec, + ) -> Result { + use tokio_stream::StreamExt; + let (app_event_tx, mut app_event_rx) = unbounded_channel(); let app_event_tx = AppEventSender::new(app_event_tx); + let conversation_manager = Arc::new(ConversationManager::default()); + let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false); - // Spawn a dedicated thread for reading the crossterm event loop and - // re-publishing the events as AppEvents, as appropriate. - { - let app_event_tx = app_event_tx.clone(); - std::thread::spawn(move || { - loop { - // This timeout is necessary to avoid holding the event lock - // that crossterm::event::read() acquires. In particular, - // reading the cursor position (crossterm::cursor::position()) - // needs to acquire the event lock, and so will fail if it - // can't acquire it within 2 sec. Resizing the terminal - // crashes the app if the cursor position can't be read. - if let Ok(true) = crossterm::event::poll(Duration::from_millis(100)) { - if let Ok(event) = crossterm::event::read() { - match event { - crossterm::event::Event::Key(key_event) => { - app_event_tx.send(AppEvent::KeyEvent(key_event)); - } - crossterm::event::Event::Resize(_, _) => { - app_event_tx.send(AppEvent::RequestRedraw); - } - crossterm::event::Event::Paste(pasted) => { - // Many terminals convert newlines to \r when pasting (e.g., iTerm2), - // but tui-textarea expects \n. Normalize CR to LF. - // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 - // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 - let pasted = pasted.replace("\r", "\n"); - app_event_tx.send(AppEvent::Paste(pasted)); - } - _ => { - // Ignore any other events. - } - } - } - } else { - // Timeout expired, no `Event` is available - } - } - }); - } - - let login_status = get_login_status(&config); - let should_show_onboarding = - should_show_onboarding(login_status, &config, show_trust_screen); - let app_state = if should_show_onboarding { - let show_login_screen = should_show_login_screen(login_status, &config); - let chat_widget_args = ChatWidgetArgs { - config: config.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - }; - AppState::Onboarding { - screen: OnboardingScreen::new(OnboardingScreenArgs { - event_tx: app_event_tx.clone(), - codex_home: config.codex_home.clone(), - cwd: config.cwd.clone(), - show_trust_screen, - show_login_screen, - chat_widget_args, - login_status, - }), - } - } else { - let chat_widget = ChatWidget::new( - config.clone(), - conversation_manager.clone(), - app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - ); - AppState::Chat { - widget: Box::new(chat_widget), - } - }; + let chat_widget = ChatWidget::new( + config.clone(), + conversation_manager.clone(), + tui.frame_requester(), + app_event_tx.clone(), + initial_prompt, + initial_images, + enhanced_keys_supported, + ); let file_search = FileSearchManager::new(config.cwd.clone(), app_event_tx.clone()); - // Spawn a single scheduler thread that coalesces both debounced redraw - // requests and animation frame requests, and emits a single Redraw event - // at the earliest requested time. - let (frame_tx, frame_rx) = channel::(); - { - let app_event_tx = app_event_tx.clone(); - std::thread::spawn(move || { - use std::sync::mpsc::RecvTimeoutError; - let mut next_deadline: Option = None; - loop { - if next_deadline.is_none() { - match frame_rx.recv() { - Ok(deadline) => next_deadline = Some(deadline), - Err(_) => break, - } - } - - #[expect(clippy::expect_used)] - let deadline = next_deadline.expect("deadline set"); - let now = Instant::now(); - let timeout = if deadline > now { - deadline - now - } else { - Duration::from_millis(0) - }; - - match frame_rx.recv_timeout(timeout) { - Ok(new_deadline) => { - next_deadline = - Some(next_deadline.map_or(new_deadline, |d| d.min(new_deadline))); - } - Err(RecvTimeoutError::Timeout) => { - app_event_tx.send(AppEvent::Redraw); - next_deadline = None; - } - Err(RecvTimeoutError::Disconnected) => break, - } - } - }); - } - Self { + let mut app = Self { server: conversation_manager, app_event_tx, - pending_history_lines: Vec::new(), - app_event_rx, - app_state, + chat_widget, config, file_search, enhanced_keys_supported, + transcript_lines: Vec::new(), + transcript_overlay: None, + deferred_history_lines: Vec::new(), + transcript_saved_viewport: None, commit_anim_running: Arc::new(AtomicBool::new(false)), - frame_schedule_tx: frame_tx, - } + }; + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + tui.frame_requester().schedule_frame(); + + while select! { + Some(event) = app_event_rx.recv() => { + app.handle_event(tui, event)? + } + Some(event) = tui_events.next() => { + app.handle_tui_event(tui, event).await? + } + } {} + tui.terminal.clear()?; + Ok(app.token_usage()) } - fn schedule_frame_in(&self, dur: Duration) { - let _ = self.frame_schedule_tx.send(Instant::now() + dur); - } - - pub(crate) fn run(&mut self, terminal: &mut tui::Tui) -> Result<()> { - // Schedule the first render immediately. - let _ = self.frame_schedule_tx.send(Instant::now()); - - while let Ok(event) = self.app_event_rx.recv() { + pub(crate) async fn handle_tui_event( + &mut self, + tui: &mut tui::Tui, + event: TuiEvent, + ) -> Result { + if let Some(overlay) = &mut self.transcript_overlay { + overlay.handle_event(tui, event)?; + if overlay.is_done { + // Exit alternate screen and restore viewport. + let _ = execute!(tui.terminal.backend_mut(), LeaveAlternateScreen); + if let Some(saved) = self.transcript_saved_viewport.take() { + tui.terminal.set_viewport_area(saved); + } + if !self.deferred_history_lines.is_empty() { + let lines = std::mem::take(&mut self.deferred_history_lines); + tui.insert_history_lines(lines); + } + self.transcript_overlay = None; + } + tui.frame_requester().schedule_frame(); + } else { match event { - AppEvent::InsertHistory(lines) => { - self.pending_history_lines.extend(lines); - self.app_event_tx.send(AppEvent::RequestRedraw); + TuiEvent::Key(key_event) => { + self.handle_key_event(tui, key_event).await; } - AppEvent::RequestRedraw => { - self.schedule_frame_in(REDRAW_DEBOUNCE); + TuiEvent::Paste(pasted) => { + // Many terminals convert newlines to \r when pasting (e.g., iTerm2), + // but tui-textarea expects \n. Normalize CR to LF. + // [tui-textarea]: https://github.com/rhysd/tui-textarea/blob/4d18622eeac13b309e0ff6a55a46ac6706da68cf/src/textarea.rs#L782-L783 + // [iTerm2]: https://github.com/gnachman/iTerm2/blob/5d0c0d9f68523cbd0494dad5422998964a2ecd8d/sources/iTermPasteHelper.m#L206-L216 + let pasted = pasted.replace("\r", "\n"); + self.chat_widget.handle_paste(pasted); } - AppEvent::ScheduleFrameIn(dur) => { - self.schedule_frame_in(dur); - } - AppEvent::Redraw => { - std::io::stdout().sync_update(|_| self.draw_next_frame(terminal))??; - } - AppEvent::StartCommitAnimation => { - if self - .commit_anim_running - .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) - .is_ok() - { - let tx = self.app_event_tx.clone(); - let running = self.commit_anim_running.clone(); - thread::spawn(move || { - while running.load(Ordering::Relaxed) { - thread::sleep(Duration::from_millis(50)); - tx.send(AppEvent::CommitTick); - } - }); - } - } - AppEvent::StopCommitAnimation => { - self.commit_anim_running.store(false, Ordering::Release); - } - AppEvent::CommitTick => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.on_commit_tick(); - } - } - AppEvent::KeyEvent(key_event) => { - match key_event { - KeyEvent { - code: KeyCode::Char('c'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => match &mut self.app_state { - AppState::Chat { widget } => { - widget.on_ctrl_c(); - } - AppState::Onboarding { .. } => { - self.app_event_tx.send(AppEvent::ExitRequest); + TuiEvent::Draw => { + tui.draw( + self.chat_widget.desired_height(tui.terminal.size()?.width), + |frame| { + frame.render_widget_ref(&self.chat_widget, frame.area()); + if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) { + frame.set_cursor_position((x, y)); } }, - KeyEvent { - code: KeyCode::Char('z'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - #[cfg(unix)] - { - self.suspend(terminal)?; - } - // No-op on non-Unix platforms. - } - KeyEvent { - code: KeyCode::Char('d'), - modifiers: crossterm::event::KeyModifiers::CONTROL, - kind: KeyEventKind::Press, - .. - } => { - match &mut self.app_state { - AppState::Chat { widget } => { - if widget.composer_is_empty() { - self.app_event_tx.send(AppEvent::ExitRequest); - } else { - // Treat Ctrl+D as a normal key event when the composer - // is not empty so that it doesn't quit the application - // prematurely. - self.dispatch_key_event(key_event); - } - } - AppState::Onboarding { .. } => { - self.app_event_tx.send(AppEvent::ExitRequest); - } - } - } - KeyEvent { - kind: KeyEventKind::Press | KeyEventKind::Repeat, - .. - } => { - self.dispatch_key_event(key_event); - } - _ => { - // Ignore Release key events. - } - }; + )?; } - AppEvent::Paste(text) => { - self.dispatch_paste_event(text); - } - AppEvent::CodexEvent(event) => { - self.dispatch_codex_event(event); - } - AppEvent::ExitRequest => { - break; - } - AppEvent::CodexOp(op) => match &mut self.app_state { - AppState::Chat { widget } => widget.submit_op(op), - AppState::Onboarding { .. } => {} - }, - AppEvent::DiffResult(text) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_output(text); - } - } - AppEvent::DispatchCommand(command) => match command { - SlashCommand::New => { - // User accepted – switch to chat view. - let new_widget = Box::new(ChatWidget::new( - self.config.clone(), - self.server.clone(), - self.app_event_tx.clone(), - None, - Vec::new(), - self.enhanced_keys_supported, - )); - self.app_state = AppState::Chat { widget: new_widget }; - self.app_event_tx.send(AppEvent::RequestRedraw); - } - SlashCommand::Init => { - // Guard: do not run if a task is active. - if let AppState::Chat { widget } = &mut self.app_state { - const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); - widget.submit_text_message(INIT_PROMPT.to_string()); - } - } - SlashCommand::Compact => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.clear_token_usage(); - self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); - } - } - SlashCommand::Model => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_model_popup(); - } - } - SlashCommand::Approvals => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.open_approvals_popup(); - } - } - SlashCommand::Quit => { - break; - } - SlashCommand::Logout => { - if let Err(e) = codex_login::logout(&self.config.codex_home) { - tracing::error!("failed to logout: {e}"); - } - break; - } - SlashCommand::Diff => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_diff_in_progress(); - } - - let tx = self.app_event_tx.clone(); - tokio::spawn(async move { - let text = match get_git_diff().await { - Ok((is_git_repo, diff_text)) => { - if is_git_repo { - diff_text - } else { - "`/diff` — _not inside a git repository_".to_string() - } - } - Err(e) => format!("Failed to compute diff: {e}"), - }; - tx.send(AppEvent::DiffResult(text)); - }); - } - SlashCommand::Mention => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.insert_str("@"); - } - } - SlashCommand::Status => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_status_output(); - } - } - SlashCommand::Mcp => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.add_mcp_output(); - } - } - #[cfg(debug_assertions)] - SlashCommand::TestApproval => { - use codex_core::protocol::EventMsg; - use std::collections::HashMap; - - use codex_core::protocol::ApplyPatchApprovalRequestEvent; - use codex_core::protocol::FileChange; - - self.app_event_tx.send(AppEvent::CodexEvent(Event { - id: "1".to_string(), - // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { - // call_id: "1".to_string(), - // command: vec!["git".into(), "apply".into()], - // cwd: self.config.cwd.clone(), - // reason: Some("test".to_string()), - // }), - msg: EventMsg::ApplyPatchApprovalRequest( - ApplyPatchApprovalRequestEvent { - call_id: "1".to_string(), - changes: HashMap::from([ - ( - PathBuf::from("/tmp/test.txt"), - FileChange::Add { - content: "test".to_string(), - }, - ), - ( - PathBuf::from("/tmp/test2.txt"), - FileChange::Update { - unified_diff: "+test\n-test2".to_string(), - move_path: None, - }, - ), - ]), - reason: None, - grant_root: Some(PathBuf::from("/tmp")), - }, - ), - })); - } - }, - AppEvent::OnboardingAuthComplete(result) => { - if let AppState::Onboarding { screen } = &mut self.app_state { - screen.on_auth_complete(result); - } - } - AppEvent::OnboardingComplete(ChatWidgetArgs { - config, - enhanced_keys_supported, - initial_images, - initial_prompt, - }) => { - self.app_state = AppState::Chat { - widget: Box::new(ChatWidget::new( - config, - self.server.clone(), - self.app_event_tx.clone(), - initial_prompt, - initial_images, - enhanced_keys_supported, - )), - } - } - AppEvent::StartFileSearch(query) => { - if !query.is_empty() { - self.file_search.on_user_query(query); - } - } - AppEvent::FileSearchResult { query, matches } => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.apply_file_search_result(query, matches); - } - } - AppEvent::UpdateReasoningEffort(effort) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_reasoning_effort(effort); - } - } - AppEvent::UpdateModel(model) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_model(model); - } - } - AppEvent::UpdateAskForApprovalPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_approval_policy(policy); - } - } - AppEvent::UpdateSandboxPolicy(policy) => { - if let AppState::Chat { widget } = &mut self.app_state { - widget.set_sandbox_policy(policy); - } + #[cfg(unix)] + TuiEvent::ResumeFromSuspend => { + let cursor_pos = tui.terminal.get_cursor_position()?; + tui.terminal.set_viewport_area(ratatui::layout::Rect::new( + 0, + cursor_pos.y, + 0, + 0, + )); } } } - terminal.clear()?; - - Ok(()) + Ok(true) } - #[cfg(unix)] - fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> { - tui::restore()?; - // SAFETY: Unix-only code path. We intentionally send SIGTSTP to the - // current process group (pid 0) to trigger standard job-control - // suspension semantics. This FFI does not involve any raw pointers, - // is not called from a signal handler, and uses a constant signal. - // Errors from kill are acceptable (e.g., if already stopped) — the - // subsequent re-init path will still leave the terminal in a good state. - // We considered `nix`, but didn't think it was worth pulling in for this one call. - unsafe { libc::kill(0, libc::SIGTSTP) }; - *terminal = tui::init(&self.config)?; - terminal.clear()?; - self.app_event_tx.send(AppEvent::RequestRedraw); - Ok(()) + fn handle_event(&mut self, tui: &mut tui::Tui, event: AppEvent) -> Result { + match event { + AppEvent::NewSession => { + self.chat_widget = ChatWidget::new( + self.config.clone(), + self.server.clone(), + tui.frame_requester(), + self.app_event_tx.clone(), + None, + Vec::new(), + self.enhanced_keys_supported, + ); + tui.frame_requester().schedule_frame(); + } + AppEvent::InsertHistoryLines(lines) => { + if let Some(overlay) = &mut self.transcript_overlay { + overlay.insert_lines(lines.clone()); + tui.frame_requester().schedule_frame(); + } + self.transcript_lines.extend(lines.clone()); + if self.transcript_overlay.is_some() { + self.deferred_history_lines.extend(lines); + } else { + tui.insert_history_lines(lines); + } + } + AppEvent::InsertHistoryCell(cell) => { + if let Some(overlay) = &mut self.transcript_overlay { + overlay.insert_lines(cell.transcript_lines()); + tui.frame_requester().schedule_frame(); + } + self.transcript_lines.extend(cell.transcript_lines()); + let display = cell.display_lines(); + if !display.is_empty() { + if self.transcript_overlay.is_some() { + self.deferred_history_lines.extend(display); + } else { + tui.insert_history_lines(display); + } + } + } + AppEvent::StartCommitAnimation => { + if self + .commit_anim_running + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_ok() + { + let tx = self.app_event_tx.clone(); + let running = self.commit_anim_running.clone(); + thread::spawn(move || { + while running.load(Ordering::Relaxed) { + thread::sleep(Duration::from_millis(50)); + tx.send(AppEvent::CommitTick); + } + }); + } + } + AppEvent::StopCommitAnimation => { + self.commit_anim_running.store(false, Ordering::Release); + } + AppEvent::CommitTick => { + self.chat_widget.on_commit_tick(); + } + AppEvent::CodexEvent(event) => { + self.chat_widget.handle_codex_event(event); + } + AppEvent::ExitRequest => { + return Ok(false); + } + AppEvent::CodexOp(op) => self.chat_widget.submit_op(op), + AppEvent::DiffResult(text) => { + self.chat_widget.add_diff_output(text); + } + AppEvent::StartFileSearch(query) => { + if !query.is_empty() { + self.file_search.on_user_query(query); + } + } + AppEvent::FileSearchResult { query, matches } => { + self.chat_widget.apply_file_search_result(query, matches); + } + AppEvent::UpdateReasoningEffort(effort) => { + self.chat_widget.set_reasoning_effort(effort); + } + AppEvent::UpdateModel(model) => { + self.chat_widget.set_model(model); + } + AppEvent::UpdateAskForApprovalPolicy(policy) => { + self.chat_widget.set_approval_policy(policy); + } + AppEvent::UpdateSandboxPolicy(policy) => { + self.chat_widget.set_sandbox_policy(policy); + } + } + Ok(true) } pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage { - match &self.app_state { - AppState::Chat { widget } => widget.token_usage().clone(), - AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(), - } + self.chat_widget.token_usage().clone() } - fn draw_next_frame(&mut self, terminal: &mut tui::Tui) -> Result<()> { - if matches!(self.app_state, AppState::Onboarding { .. }) { - terminal.clear()?; - } - - let screen_size = terminal.size()?; - let last_known_screen_size = terminal.last_known_screen_size; - if screen_size != last_known_screen_size { - let cursor_pos = terminal.get_cursor_position()?; - let last_known_cursor_pos = terminal.last_known_cursor_pos; - if cursor_pos.y != last_known_cursor_pos.y { - // The terminal was resized. The only point of reference we have for where our viewport - // was moved is the cursor position. - // NB this assumes that the cursor was not wrapped as part of the resize. - let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; - - let new_viewport_area = terminal.viewport_area.offset(Offset { - x: 0, - y: cursor_delta, - }); - terminal.set_viewport_area(new_viewport_area); - terminal.clear()?; + async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.chat_widget.on_ctrl_c(); } - } + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } if self.chat_widget.composer_is_empty() => { + self.app_event_tx.send(AppEvent::ExitRequest); + } + KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + // Enter alternate screen and set viewport to full size. + let _ = execute!(tui.terminal.backend_mut(), EnterAlternateScreen); + if let Ok(size) = tui.terminal.size() { + self.transcript_saved_viewport = Some(tui.terminal.viewport_area); + tui.terminal + .set_viewport_area(Rect::new(0, 0, size.width, size.height)); + let _ = tui.terminal.clear(); + } - let size = terminal.size()?; - let desired_height = match &self.app_state { - AppState::Chat { widget } => widget.desired_height(size.width), - AppState::Onboarding { .. } => size.height, + self.transcript_overlay = Some(TranscriptApp::new(self.transcript_lines.clone())); + tui.frame_requester().schedule_frame(); + } + KeyEvent { + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.chat_widget.handle_key_event(key_event); + } + _ => { + // Ignore Release key events. + } }; - - let mut area = terminal.viewport_area; - area.height = desired_height.min(size.height); - area.width = size.width; - if area.bottom() > size.height { - terminal - .backend_mut() - .scroll_region_up(0..area.top(), area.bottom() - size.height)?; - area.y = size.height - area.height; - } - if area != terminal.viewport_area { - terminal.clear()?; - terminal.set_viewport_area(area); - } - if !self.pending_history_lines.is_empty() { - crate::insert_history::insert_history_lines( - terminal, - self.pending_history_lines.clone(), - ); - self.pending_history_lines.clear(); - } - terminal.draw(|frame| match &mut self.app_state { - AppState::Chat { widget } => { - if let Some((x, y)) = widget.cursor_pos(frame.area()) { - frame.set_cursor_position((x, y)); - } - frame.render_widget_ref(&**widget, frame.area()) - } - AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()), - })?; - Ok(()) - } - - /// Dispatch a KeyEvent to the current view and let it decide what to do - /// with it. - fn dispatch_key_event(&mut self, key_event: KeyEvent) { - match &mut self.app_state { - AppState::Chat { widget } => { - widget.handle_key_event(key_event); - } - AppState::Onboarding { screen } => match key_event.code { - KeyCode::Char('q') => { - self.app_event_tx.send(AppEvent::ExitRequest); - } - _ => screen.handle_key_event(key_event), - }, - } - } - - fn dispatch_paste_event(&mut self, pasted: String) { - match &mut self.app_state { - AppState::Chat { widget } => widget.handle_paste(pasted), - AppState::Onboarding { .. } => {} - } - } - - fn dispatch_codex_event(&mut self, event: Event) { - match &mut self.app_state { - AppState::Chat { widget } => widget.handle_codex_event(event), - AppState::Onboarding { .. } => {} - } - } -} - -fn should_show_onboarding( - login_status: LoginStatus, - config: &Config, - show_trust_screen: bool, -) -> bool { - if show_trust_screen { - return true; - } - - should_show_login_screen(login_status, config) -} - -fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { - // Only show the login screen for providers that actually require OpenAI auth - // (OpenAI or equivalents). For OSS/other providers, skip login entirely. - if !config.model_provider.requires_openai_auth { - return false; - } - - match login_status { - LoginStatus::NotAuthenticated => true, - LoginStatus::AuthMode(method) => method != config.preferred_auth_method, - } -} - -#[cfg(test)] -mod tests { - use super::*; - use codex_core::config::ConfigOverrides; - use codex_core::config::ConfigToml; - use codex_login::AuthMode; - - fn make_config(preferred: AuthMode) -> Config { - let mut cfg = Config::load_from_base_config_with_overrides( - ConfigToml::default(), - ConfigOverrides::default(), - std::env::temp_dir(), - ) - .expect("load default config"); - cfg.preferred_auth_method = preferred; - cfg - } - - #[test] - fn shows_login_when_not_authenticated() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(should_show_login_screen( - LoginStatus::NotAuthenticated, - &cfg - )); - } - - #[test] - fn shows_login_when_api_key_but_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_api_key_and_prefers_api_key() { - let cfg = make_config(AuthMode::ApiKey); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ApiKey), - &cfg - )) - } - - #[test] - fn hides_login_when_chatgpt_and_prefers_chatgpt() { - let cfg = make_config(AuthMode::ChatGPT); - assert!(!should_show_login_screen( - LoginStatus::AuthMode(AuthMode::ChatGPT), - &cfg - )) } } diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index fc8b510ff8..a961f19e34 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -1,11 +1,9 @@ use codex_core::protocol::Event; use codex_file_search::FileMatch; -use crossterm::event::KeyEvent; use ratatui::text::Line; -use std::time::Duration; -use crate::app::ChatWidgetArgs; -use crate::slash_command::SlashCommand; +use crate::history_cell::HistoryCell; + use codex_core::protocol::AskForApproval; use codex_core::protocol::SandboxPolicy; use codex_core::protocol_config_types::ReasoningEffort; @@ -15,20 +13,8 @@ use codex_core::protocol_config_types::ReasoningEffort; pub(crate) enum AppEvent { CodexEvent(Event), - /// Request a redraw which will be debounced by the [`App`]. - RequestRedraw, - - /// Actually draw the next frame. - Redraw, - - /// Schedule a one-shot animation frame roughly after the given duration. - /// Multiple requests are coalesced by the central frame scheduler. - ScheduleFrameIn(Duration), - - KeyEvent(KeyEvent), - - /// Text pasted from the terminal clipboard. - Paste(String), + /// Start a new session. + NewSession, /// Request to exit the application gracefully. ExitRequest, @@ -37,10 +23,6 @@ pub(crate) enum AppEvent { /// bubbling channels through layers of widgets. CodexOp(codex_core::protocol::Op), - /// Dispatch a recognized slash command from the UI (composer) to the app - /// layer so it can be handled centrally. - DispatchCommand(SlashCommand), - /// Kick off an asynchronous file search for the given query (text after /// the `@`). Previous searches may be cancelled by the app layer so there /// is at most one in-flight search. @@ -57,16 +39,13 @@ pub(crate) enum AppEvent { /// Result of computing a `/diff` command. DiffResult(String), - InsertHistory(Vec>), + InsertHistoryLines(Vec>), + InsertHistoryCell(Box), StartCommitAnimation, StopCommitAnimation, CommitTick, - /// Onboarding: result of login_with_chatgpt. - OnboardingAuthComplete(Result<(), String>), - OnboardingComplete(ChatWidgetArgs), - /// Update the current reasoning effort in the running app and widget. UpdateReasoningEffort(ReasoningEffort), diff --git a/codex-rs/tui/src/app_event_sender.rs b/codex-rs/tui/src/app_event_sender.rs index 901bb41024..c1427b3ff0 100644 --- a/codex-rs/tui/src/app_event_sender.rs +++ b/codex-rs/tui/src/app_event_sender.rs @@ -1,15 +1,15 @@ -use std::sync::mpsc::Sender; +use tokio::sync::mpsc::UnboundedSender; use crate::app_event::AppEvent; use crate::session_log; #[derive(Clone, Debug)] pub(crate) struct AppEventSender { - pub app_event_tx: Sender, + pub app_event_tx: UnboundedSender, } impl AppEventSender { - pub(crate) fn new(app_event_tx: Sender) -> Self { + pub(crate) fn new(app_event_tx: UnboundedSender) -> Self { Self { app_event_tx } } diff --git a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs index b7e6e5e69a..518d9d0351 100644 --- a/codex-rs/tui/src/bottom_pane/approval_modal_view.rs +++ b/codex-rs/tui/src/bottom_pane/approval_modal_view.rs @@ -12,13 +12,13 @@ use super::BottomPaneView; use super::CancellationEvent; /// Modal overlay asking the user to approve/deny a sequence of requests. -pub(crate) struct ApprovalModalView<'a> { - current: UserApprovalWidget<'a>, +pub(crate) struct ApprovalModalView { + current: UserApprovalWidget, queue: Vec, app_event_tx: AppEventSender, } -impl ApprovalModalView<'_> { +impl ApprovalModalView { pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { Self { current: UserApprovalWidget::new(request, app_event_tx.clone()), @@ -41,13 +41,13 @@ impl ApprovalModalView<'_> { } } -impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { - fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, key_event: KeyEvent) { +impl BottomPaneView for ApprovalModalView { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { self.current.handle_key_event(key_event); self.maybe_advance(); } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { self.current.on_ctrl_c(); self.queue.clear(); CancellationEvent::Handled @@ -75,7 +75,7 @@ impl<'a> BottomPaneView<'a> for ApprovalModalView<'a> { mod tests { use super::*; use crate::app_event::AppEvent; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; fn make_exec_request() -> ApprovalRequest { ApprovalRequest::Exec { @@ -87,15 +87,16 @@ mod tests { #[test] fn ctrl_c_aborts_and_clears_queue() { - let (tx_raw, _rx) = channel::(); - let tx = AppEventSender::new(tx_raw); + let (tx, _rx) = unbounded_channel::(); + let tx = AppEventSender::new(tx); let first = make_exec_request(); let mut view = ApprovalModalView::new(first, tx); view.enqueue_request(make_exec_request()); - let (tx_raw2, _rx2) = channel::(); + let (tx2, _rx2) = unbounded_channel::(); let mut pane = BottomPane::new(super::super::BottomPaneParams { - app_event_tx: AppEventSender::new(tx_raw2), + app_event_tx: AppEventSender::new(tx2), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs index 31a32140cb..6a6d2aa89e 100644 --- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs +++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs @@ -7,10 +7,10 @@ use super::BottomPane; use super::CancellationEvent; /// Trait implemented by every view that can be shown in the bottom pane. -pub(crate) trait BottomPaneView<'a> { +pub(crate) trait BottomPaneView { /// Handle a key event while the view is active. A redraw is always /// scheduled after this call. - fn handle_key_event(&mut self, _pane: &mut BottomPane<'a>, _key_event: KeyEvent) {} + fn handle_key_event(&mut self, _pane: &mut BottomPane, _key_event: KeyEvent) {} /// Return `true` if the view has finished and should be removed. fn is_complete(&self) -> bool { @@ -18,7 +18,7 @@ pub(crate) trait BottomPaneView<'a> { } /// Handle Ctrl-C while this view is active. - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'a>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { CancellationEvent::Ignored } @@ -28,6 +28,11 @@ pub(crate) trait BottomPaneView<'a> { /// Render the view: this will be displayed in place of the composer. fn render(&self, area: Rect, buf: &mut Buffer); + /// Update the status indicator animated header. Default no-op. + fn update_status_header(&mut self, _header: String) { + // no-op + } + /// Called when task completes to check if the view should be hidden. fn should_hide_when_task_is_done(&mut self) -> bool { false diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 5d555aa083..0cc34542c5 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -23,6 +23,7 @@ use ratatui::widgets::WidgetRef; use super::chat_composer_history::ChatComposerHistory; use super::command_popup::CommandPopup; use super::file_search_popup::FileSearchPopup; +use crate::slash_command::SlashCommand; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; @@ -38,6 +39,7 @@ const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000; /// Result returned when the user interacts with the text area. pub enum InputResult { Submitted(String), + Command(SlashCommand), None, } @@ -275,6 +277,11 @@ impl ChatComposer { self.textarea.set_text(&format!("/{} ", cmd.command())); self.textarea.set_cursor(self.textarea.text().len()); } + // After completing the command, move cursor to the end. + if !self.textarea.text().is_empty() { + let end = self.textarea.text().len(); + self.textarea.set_cursor(end); + } } (InputResult::None, true) } @@ -284,15 +291,15 @@ impl ChatComposer { .. } => { if let Some(cmd) = popup.selected_command() { - // Send command to the app layer. - self.app_event_tx.send(AppEvent::DispatchCommand(*cmd)); - // Clear textarea so no residual text remains. self.textarea.set_text(""); + let result = (InputResult::Command(*cmd), true); + // Hide popup since the command has been dispatched. self.active_popup = ActivePopup::None; - return (InputResult::None, true); + + return result; } // Fallback to default newline handling if no command selected. self.handle_key_event_without_popup(key_event) @@ -745,6 +752,7 @@ mod tests { use crate::bottom_pane::InputResult; use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD; use crate::bottom_pane::textarea::TextArea; + use tokio::sync::mpsc::unbounded_channel; #[test] fn test_current_at_token_basic_cases() { @@ -901,7 +909,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -925,7 +933,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -955,7 +963,7 @@ mod tests { use crossterm::event::KeyModifiers; let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1); - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -977,7 +985,7 @@ mod tests { use ratatui::Terminal; use ratatui::backend::TestBackend; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut terminal = match Terminal::new(TestBackend::new(100, 10)) { Ok(t) => t, @@ -1033,9 +1041,8 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use std::sync::mpsc::TryRecvError; - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1051,25 +1058,18 @@ mod tests { let (result, _needs_redraw) = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); - // When a slash command is dispatched, the composer should not submit - // literal text and should clear its textarea. + // When a slash command is dispatched, the composer should return a + // Command result (not submit literal text) and clear its textarea. match result { - InputResult::None => {} + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "init"); + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::None => panic!("expected Command result for '/init'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); - - // Verify a DispatchCommand event for the "init" command was sent. - match rx.try_recv() { - Ok(AppEvent::DispatchCommand(cmd)) => { - assert_eq!(cmd.command(), "init"); - } - Ok(_other) => panic!("unexpected app event"), - Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/init'"), - Err(TryRecvError::Disconnected) => panic!("app event channel disconnected"), - } } #[test] @@ -1078,7 +1078,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1099,9 +1099,8 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use std::sync::mpsc::TryRecvError; - let (tx, rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1114,24 +1113,16 @@ mod tests { composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match result { - InputResult::None => {} + InputResult::Command(cmd) => { + assert_eq!(cmd.command(), "mention"); + } InputResult::Submitted(text) => { panic!("expected command dispatch, but composer submitted literal text: {text}") } + InputResult::None => panic!("expected Command result for '/mention'"), } assert!(composer.textarea.is_empty(), "composer should be cleared"); - - match rx.try_recv() { - Ok(AppEvent::DispatchCommand(cmd)) => { - assert_eq!(cmd.command(), "mention"); - composer.insert_str("@"); - } - Ok(_other) => panic!("unexpected app event"), - Err(TryRecvError::Empty) => panic!("expected a DispatchCommand event for '/mention'"), - Err(TryRecvError::Disconnected) => { - panic!("app event channel disconnected") - } - } + composer.insert_str("@"); assert_eq!(composer.textarea.text(), "@"); } @@ -1141,7 +1132,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1215,7 +1206,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); @@ -1282,7 +1273,7 @@ mod tests { use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - let (tx, _rx) = std::sync::mpsc::channel(); + let (tx, _rx) = unbounded_channel::(); let sender = AppEventSender::new(tx); let mut composer = ChatComposer::new(true, sender, false, "Ask Codex to do anything".to_string()); diff --git a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs index 04b745d1ff..87bcc438e9 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer_history.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer_history.rs @@ -192,7 +192,7 @@ mod tests { use super::*; use crate::app_event::AppEvent; use codex_core::protocol::Op; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; #[test] fn duplicate_submissions_are_not_recorded() { @@ -219,7 +219,7 @@ mod tests { #[test] fn navigation_with_async_fetch() { - let (tx, rx) = channel::(); + let (tx, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx); let mut history = ChatComposerHistory::new(); diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 3b03eb9c03..74b5274f26 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -105,8 +105,8 @@ impl ListSelectionView { } } -impl BottomPaneView<'_> for ListSelectionView { - fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { +impl BottomPaneView for ListSelectionView { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { match key_event { KeyEvent { code: KeyCode::Up, .. @@ -131,7 +131,7 @@ impl BottomPaneView<'_> for ListSelectionView { self.complete } - fn on_ctrl_c(&mut self, _pane: &mut BottomPane<'_>) -> CancellationEvent { + fn on_ctrl_c(&mut self, _pane: &mut BottomPane) -> CancellationEvent { self.complete = true; CancellationEvent::Handled } diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index b27ea6e945..48ad3f02b5 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -1,7 +1,7 @@ //! Bottom pane: shows the ChatComposer or a BottomPaneView, if one is active. -use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; +use crate::tui::FrameRequester; use crate::user_approval_widget::ApprovalRequest; use bottom_pane_view::BottomPaneView; use codex_core::protocol::TokenUsage; @@ -39,15 +39,17 @@ pub(crate) use list_selection_view::SelectionItem; use status_indicator_view::StatusIndicatorView; /// Pane displayed in the lower half of the chat UI. -pub(crate) struct BottomPane<'a> { +pub(crate) struct BottomPane { /// Composer is retained even when a BottomPaneView is displayed so the /// input state is retained when the view is closed. composer: ChatComposer, /// If present, this is displayed instead of the `composer`. - active_view: Option + 'a>>, + active_view: Option>, app_event_tx: AppEventSender, + frame_requester: FrameRequester, + has_input_focus: bool, is_task_running: bool, ctrl_c_quit_hint: bool, @@ -59,12 +61,13 @@ pub(crate) struct BottomPane<'a> { pub(crate) struct BottomPaneParams { pub(crate) app_event_tx: AppEventSender, + pub(crate) frame_requester: FrameRequester, pub(crate) has_input_focus: bool, pub(crate) enhanced_keys_supported: bool, pub(crate) placeholder_text: String, } -impl BottomPane<'_> { +impl BottomPane { const BOTTOM_PAD_LINES: u16 = 2; pub fn new(params: BottomPaneParams) -> Self { let enhanced_keys_supported = params.enhanced_keys_supported; @@ -77,6 +80,7 @@ impl BottomPane<'_> { ), active_view: None, app_event_tx: params.app_event_tx, + frame_requester: params.frame_requester, has_input_focus: params.has_input_focus, is_task_running: false, ctrl_c_quit_hint: false, @@ -113,7 +117,10 @@ impl BottomPane<'_> { if !view.is_complete() { self.active_view = Some(view); } else if self.is_task_running { - let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + let mut v = StatusIndicatorView::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + ); v.update_text("waiting for model".to_string()); self.active_view = Some(Box::new(v)); self.status_view_active = true; @@ -144,7 +151,10 @@ impl BottomPane<'_> { self.active_view = Some(view); } else if self.is_task_running { // Modal aborted but task still running – restore status indicator. - let mut v = StatusIndicatorView::new(self.app_event_tx.clone()); + let mut v = StatusIndicatorView::new( + self.app_event_tx.clone(), + self.frame_requester.clone(), + ); v.update_text("waiting for model".to_string()); self.active_view = Some(Box::new(v)); self.status_view_active = true; @@ -172,6 +182,17 @@ impl BottomPane<'_> { self.request_redraw(); } + /// Update the animated header shown to the left of the brackets in the + /// status indicator (defaults to "Working"). This will update the active + /// StatusIndicatorView if present; otherwise, if a live overlay is active, + /// it will update that. If neither is present, this call is a no-op. + pub(crate) fn update_status_header(&mut self, header: String) { + if let Some(view) = self.active_view.as_mut() { + view.update_status_header(header.clone()); + self.request_redraw(); + } + } + pub(crate) fn show_ctrl_c_quit_hint(&mut self) { self.ctrl_c_quit_hint = true; self.composer @@ -199,6 +220,7 @@ impl BottomPane<'_> { if self.active_view.is_none() { self.active_view = Some(Box::new(StatusIndicatorView::new( self.app_event_tx.clone(), + self.frame_requester.clone(), ))); self.status_view_active = true; } @@ -292,7 +314,7 @@ impl BottomPane<'_> { /// Height (terminal rows) required by the current bottom pane. pub(crate) fn request_redraw(&self) { - self.app_event_tx.send(AppEvent::RequestRedraw) + self.frame_requester.schedule_frame(); } // --- History helpers --- @@ -322,7 +344,7 @@ impl BottomPane<'_> { } } -impl WidgetRef for &BottomPane<'_> { +impl WidgetRef for &BottomPane { fn render_ref(&self, area: Rect, buf: &mut Buffer) { if let Some(view) = &self.active_view { // Reserve bottom padding lines; keep at least 1 line for the view. @@ -359,7 +381,7 @@ mod tests { use crate::app_event::AppEvent; use ratatui::buffer::Buffer; use ratatui::layout::Rect; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; fn exec_request() -> ApprovalRequest { ApprovalRequest::Exec { @@ -371,10 +393,11 @@ mod tests { #[test] fn ctrl_c_on_modal_consumes_and_shows_quit_hint() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -389,10 +412,11 @@ mod tests { #[test] fn overlay_not_shown_above_approval_modal() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -418,10 +442,11 @@ mod tests { #[test] fn composer_not_shown_after_denied_if_task_running() { - let (tx_raw, rx) = channel::(); + let (tx_raw, rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -468,10 +493,11 @@ mod tests { #[test] fn status_indicator_visible_during_command_execution() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -500,10 +526,11 @@ mod tests { #[test] fn bottom_padding_present_for_status_view() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -552,10 +579,11 @@ mod tests { #[test] fn bottom_padding_shrinks_when_tiny() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let mut pane = BottomPane::new(BottomPaneParams { app_event_tx: tx, + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), diff --git a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs index ccfd240b58..7aeade1bc8 100644 --- a/codex-rs/tui/src/bottom_pane/status_indicator_view.rs +++ b/codex-rs/tui/src/bottom_pane/status_indicator_view.rs @@ -6,6 +6,7 @@ use ratatui::widgets::WidgetRef; use crate::app_event_sender::AppEventSender; use crate::bottom_pane::BottomPane; use crate::status_indicator_widget::StatusIndicatorWidget; +use crate::tui::FrameRequester; use super::BottomPaneView; @@ -14,18 +15,26 @@ pub(crate) struct StatusIndicatorView { } impl StatusIndicatorView { - pub fn new(app_event_tx: AppEventSender) -> Self { + pub fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { - view: StatusIndicatorWidget::new(app_event_tx), + view: StatusIndicatorWidget::new(app_event_tx, frame_requester), } } pub fn update_text(&mut self, text: String) { self.view.update_text(text); } + + pub fn update_header(&mut self, header: String) { + self.view.update_header(header); + } } -impl BottomPaneView<'_> for StatusIndicatorView { +impl BottomPaneView for StatusIndicatorView { + fn update_status_header(&mut self, header: String) { + self.update_header(header); + } + fn should_hide_when_task_is_done(&mut self) -> bool { true } @@ -38,7 +47,7 @@ impl BottomPaneView<'_> for StatusIndicatorView { self.view.render_ref(area, buf); } - fn handle_key_event(&mut self, _pane: &mut BottomPane<'_>, key_event: KeyEvent) { + fn handle_key_event(&mut self, _pane: &mut BottomPane, key_event: KeyEvent) { if key_event.code == KeyCode::Esc { self.view.interrupt(); } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 0c72b62134..fa18342b53 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -23,6 +23,7 @@ use codex_core::protocol::McpToolCallBeginEvent; use codex_core::protocol::McpToolCallEndEvent; use codex_core::protocol::Op; use codex_core::protocol::PatchApplyBeginEvent; +use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TokenUsage; use codex_core::protocol::TurnDiffEvent; @@ -47,11 +48,14 @@ use crate::bottom_pane::CancellationEvent; use crate::bottom_pane::InputResult; use crate::bottom_pane::SelectionAction; use crate::bottom_pane::SelectionItem; +use crate::get_git_diff::get_git_diff; use crate::history_cell; use crate::history_cell::CommandOutput; use crate::history_cell::ExecCell; use crate::history_cell::HistoryCell; use crate::history_cell::PatchEventType; +use crate::slash_command::SlashCommand; +use crate::tui::FrameRequester; // streaming internals are provided by crate::streaming and crate::markdown_stream use crate::user_approval_widget::ApprovalRequest; mod interrupts; @@ -77,10 +81,10 @@ struct RunningCommand { parsed_cmd: Vec, } -pub(crate) struct ChatWidget<'a> { +pub(crate) struct ChatWidget { app_event_tx: AppEventSender, codex_op_tx: UnboundedSender, - bottom_pane: BottomPane<'a>, + bottom_pane: BottomPane, active_exec_cell: Option, config: Config, initial_user_message: Option, @@ -88,8 +92,6 @@ pub(crate) struct ChatWidget<'a> { last_token_usage: TokenUsage, // Stream lifecycle controller stream: StreamController, - // Track the most recently active stream kind in the current turn - last_stream_kind: Option, running_commands: HashMap, pending_exec_completions: Vec<(Vec, Vec, CommandOutput)>, task_complete_pending: bool, @@ -97,7 +99,12 @@ pub(crate) struct ChatWidget<'a> { interrupts: InterruptManager, // Whether a redraw is needed after handling the current event needs_redraw: bool, + // Accumulates the current reasoning block text to extract a header + reasoning_buffer: String, + // Accumulates full reasoning content for transcript-only recording + full_reasoning_buffer: String, session_id: Option, + frame_requester: FrameRequester, } struct UserMessage { @@ -105,8 +112,6 @@ struct UserMessage { image_paths: Vec, } -use crate::streaming::StreamKind; - impl From for UserMessage { fn from(text: String) -> Self { Self { @@ -124,21 +129,21 @@ fn create_initial_user_message(text: String, image_paths: Vec) -> Optio } } -impl ChatWidget<'_> { +impl ChatWidget { #[inline] fn mark_needs_redraw(&mut self) { self.needs_redraw = true; } fn flush_answer_stream_with_separator(&mut self) { let sink = AppEventHistorySink(self.app_event_tx.clone()); - let _ = self.stream.finalize(StreamKind::Answer, true, &sink); + let _ = self.stream.finalize(true, &sink); } // --- Small event handlers --- fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) { self.bottom_pane .set_history_metadata(event.history_log_id, event.history_entry_count); self.session_id = Some(event.session_id); - self.add_to_history(&history_cell::new_session_info(&self.config, event, true)); + self.add_to_history(history_cell::new_session_info(&self.config, event, true)); if let Some(user_message) = self.initial_user_message.take() { self.submit_user_message(user_message); } @@ -148,30 +153,48 @@ impl ChatWidget<'_> { fn on_agent_message(&mut self, message: String) { let sink = AppEventHistorySink(self.app_event_tx.clone()); let finished = self.stream.apply_final_answer(&message, &sink); - self.last_stream_kind = Some(StreamKind::Answer); self.handle_if_stream_finished(finished); self.mark_needs_redraw(); } fn on_agent_message_delta(&mut self, delta: String) { - self.handle_streaming_delta(StreamKind::Answer, delta); + self.handle_streaming_delta(delta); } fn on_agent_reasoning_delta(&mut self, delta: String) { - self.handle_streaming_delta(StreamKind::Reasoning, delta); + // For reasoning deltas, do not stream to history. Accumulate the + // current reasoning block and extract the first bold element + // (between **/**) as the chunk header. Show this header as status. + self.reasoning_buffer.push_str(&delta); + + if let Some(header) = extract_first_bold(&self.reasoning_buffer) { + // Update the shimmer header to the extracted reasoning chunk header. + self.bottom_pane.update_status_header(header); + } else { + // Fallback while we don't yet have a bold header: leave existing header as-is. + } + self.mark_needs_redraw(); } - fn on_agent_reasoning_final(&mut self, text: String) { - let sink = AppEventHistorySink(self.app_event_tx.clone()); - let finished = self.stream.apply_final_reasoning(&text, &sink); - self.last_stream_kind = Some(StreamKind::Reasoning); - self.handle_if_stream_finished(finished); + fn on_agent_reasoning_final(&mut self) { + // At the end of a reasoning block, record transcript-only content. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + if !self.full_reasoning_buffer.is_empty() { + self.add_to_history(history_cell::new_reasoning_block( + self.full_reasoning_buffer.clone(), + &self.config, + )); + } + self.reasoning_buffer.clear(); + self.full_reasoning_buffer.clear(); self.mark_needs_redraw(); } fn on_reasoning_section_break(&mut self) { - let sink = AppEventHistorySink(self.app_event_tx.clone()); - self.stream.insert_reasoning_section_break(&sink); + // Start a new reasoning block for header extraction and accumulate transcript. + self.full_reasoning_buffer.push_str(&self.reasoning_buffer); + self.full_reasoning_buffer.push_str("\n\n"); + self.reasoning_buffer.clear(); } // Raw reasoning uses the same flow as summarized reasoning @@ -180,7 +203,8 @@ impl ChatWidget<'_> { self.bottom_pane.clear_ctrl_c_quit_hint(); self.bottom_pane.set_task_running(true); self.stream.reset_headers_for_new_turn(); - self.last_stream_kind = None; + self.full_reasoning_buffer.clear(); + self.reasoning_buffer.clear(); self.mark_needs_redraw(); } @@ -189,9 +213,7 @@ impl ChatWidget<'_> { // without emitting stray headers for other streams. if self.stream.is_write_cycle_active() { let sink = AppEventHistorySink(self.app_event_tx.clone()); - if let Some(kind) = self.last_stream_kind { - let _ = self.stream.finalize(kind, true, &sink); - } + let _ = self.stream.finalize(true, &sink); } // Mark task stopped and request redraw now that all content is in history. self.bottom_pane.set_task_running(false); @@ -210,7 +232,7 @@ impl ChatWidget<'_> { } fn on_error(&mut self, message: String) { - self.add_to_history(&history_cell::new_error_event(message)); + self.add_to_history(history_cell::new_error_event(message)); self.bottom_pane.set_task_running(false); self.running_commands.clear(); self.stream.clear_all(); @@ -218,7 +240,7 @@ impl ChatWidget<'_> { } fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) { - self.add_to_history(&history_cell::new_plan_update(update)); + self.add_to_history(history_cell::new_plan_update(update)); } fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) { @@ -253,7 +275,7 @@ impl ChatWidget<'_> { } fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) { - self.add_to_history(&history_cell::new_patch_event( + self.add_to_history(history_cell::new_patch_event( PatchEventType::ApplyBegin { auto_approved: event.auto_approved, }, @@ -308,6 +330,12 @@ impl ChatWidget<'_> { fn on_background_event(&mut self, message: String) { debug!("BackgroundEvent: {message}"); } + + fn on_stream_error(&mut self, message: String) { + // Show stream errors in the transcript so users see retry/backoff info. + self.add_to_history(history_cell::new_stream_error_event(message)); + self.mark_needs_redraw(); + } /// Periodic tick to commit at most one queued line to history with a small delay, /// animating the output. pub(crate) fn on_commit_tick(&mut self) { @@ -353,10 +381,9 @@ impl ChatWidget<'_> { } #[inline] - fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) { + fn handle_streaming_delta(&mut self, delta: String) { let sink = AppEventHistorySink(self.app_event_tx.clone()); - self.stream.begin(kind, &sink); - self.last_stream_kind = Some(kind); + self.stream.begin(&sink); self.stream.push_and_maybe_commit(&delta, &sink); self.mark_needs_redraw(); } @@ -381,7 +408,7 @@ impl ChatWidget<'_> { self.active_exec_cell = None; let pending = std::mem::take(&mut self.pending_exec_completions); for (command, parsed, output) in pending { - self.add_to_history(&history_cell::new_completed_exec_command( + self.add_to_history(history_cell::new_completed_exec_command( command, parsed, output, )); } @@ -393,9 +420,9 @@ impl ChatWidget<'_> { event: codex_core::protocol::PatchApplyEndEvent, ) { if event.success { - self.add_to_history(&history_cell::new_patch_apply_success(event.stdout)); + self.add_to_history(history_cell::new_patch_apply_success(event.stdout)); } else { - self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr)); + self.add_to_history(history_cell::new_patch_apply_failure(event.stderr)); } } @@ -417,7 +444,7 @@ impl ChatWidget<'_> { ev: ApplyPatchApprovalRequestEvent, ) { self.flush_answer_stream_with_separator(); - self.add_to_history(&history_cell::new_patch_event( + self.add_to_history(history_cell::new_patch_event( PatchEventType::ApprovalRequest, ev.changes.clone(), )); @@ -459,11 +486,11 @@ impl ChatWidget<'_> { pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation)); + self.add_to_history(history_cell::new_active_mcp_tool_call(ev.invocation)); } pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) { self.flush_answer_stream_with_separator(); - self.add_to_history(&*history_cell::new_completed_mcp_tool_call( + self.add_boxed_history(history_cell::new_completed_mcp_tool_call( 80, ev.invocation, ev.duration, @@ -500,6 +527,7 @@ impl ChatWidget<'_> { pub(crate) fn new( config: Config, conversation_manager: Arc, + frame_requester: FrameRequester, app_event_tx: AppEventSender, initial_prompt: Option, initial_images: Vec, @@ -511,8 +539,10 @@ impl ChatWidget<'_> { Self { app_event_tx: app_event_tx.clone(), + frame_requester: frame_requester.clone(), codex_op_tx, bottom_pane: BottomPane::new(BottomPaneParams { + frame_requester, app_event_tx, has_input_focus: true, enhanced_keys_supported, @@ -527,12 +557,13 @@ impl ChatWidget<'_> { total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), stream: StreamController::new(config), - last_stream_kind: None, running_commands: HashMap::new(), pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), needs_redraw: false, + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), session_id: None, } } @@ -554,10 +585,109 @@ impl ChatWidget<'_> { InputResult::Submitted(text) => { self.submit_user_message(text.into()); } + InputResult::Command(cmd) => { + self.dispatch_command(cmd); + } InputResult::None => {} } } + fn dispatch_command(&mut self, cmd: SlashCommand) { + match cmd { + SlashCommand::New => { + self.app_event_tx.send(AppEvent::NewSession); + } + SlashCommand::Init => { + // Guard: do not run if a task is active. + const INIT_PROMPT: &str = include_str!("../prompt_for_init_command.md"); + self.submit_text_message(INIT_PROMPT.to_string()); + } + SlashCommand::Compact => { + self.clear_token_usage(); + self.app_event_tx.send(AppEvent::CodexOp(Op::Compact)); + } + SlashCommand::Model => { + self.open_model_popup(); + } + SlashCommand::Approvals => { + self.open_approvals_popup(); + } + SlashCommand::Quit => { + self.app_event_tx.send(AppEvent::ExitRequest); + } + SlashCommand::Logout => { + if let Err(e) = codex_login::logout(&self.config.codex_home) { + tracing::error!("failed to logout: {e}"); + } + self.app_event_tx.send(AppEvent::ExitRequest); + } + SlashCommand::Diff => { + self.add_diff_in_progress(); + let tx = self.app_event_tx.clone(); + tokio::spawn(async move { + let text = match get_git_diff().await { + Ok((is_git_repo, diff_text)) => { + if is_git_repo { + diff_text + } else { + "`/diff` — _not inside a git repository_".to_string() + } + } + Err(e) => format!("Failed to compute diff: {e}"), + }; + tx.send(AppEvent::DiffResult(text)); + }); + } + SlashCommand::Mention => { + self.insert_str("@"); + } + SlashCommand::Status => { + self.add_status_output(); + } + SlashCommand::Mcp => { + self.add_mcp_output(); + } + #[cfg(debug_assertions)] + SlashCommand::TestApproval => { + use codex_core::protocol::EventMsg; + use std::collections::HashMap; + + use codex_core::protocol::ApplyPatchApprovalRequestEvent; + use codex_core::protocol::FileChange; + + self.app_event_tx.send(AppEvent::CodexEvent(Event { + id: "1".to_string(), + // msg: EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { + // call_id: "1".to_string(), + // command: vec!["git".into(), "apply".into()], + // cwd: self.config.cwd.clone(), + // reason: Some("test".to_string()), + // }), + msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { + call_id: "1".to_string(), + changes: HashMap::from([ + ( + PathBuf::from("/tmp/test.txt"), + FileChange::Add { + content: "test".to_string(), + }, + ), + ( + PathBuf::from("/tmp/test2.txt"), + FileChange::Update { + unified_diff: "+test\n-test2".to_string(), + move_path: None, + }, + ), + ]), + reason: None, + grant_root: Some(PathBuf::from("/tmp")), + }), + })); + } + } + } + pub(crate) fn handle_paste(&mut self, text: String) { self.bottom_pane.handle_paste(text); } @@ -565,14 +695,19 @@ impl ChatWidget<'_> { fn flush_active_exec_cell(&mut self) { if let Some(active) = self.active_exec_cell.take() { self.app_event_tx - .send(AppEvent::InsertHistory(active.display_lines())); + .send(AppEvent::InsertHistoryCell(Box::new(active))); } } - fn add_to_history(&mut self, cell: &dyn HistoryCell) { + fn add_to_history(&mut self, cell: impl HistoryCell + 'static) { self.flush_active_exec_cell(); self.app_event_tx - .send(AppEvent::InsertHistory(cell.display_lines())); + .send(AppEvent::InsertHistoryCell(Box::new(cell))); + } + + fn add_boxed_history(&mut self, cell: Box) { + self.flush_active_exec_cell(); + self.app_event_tx.send(AppEvent::InsertHistoryCell(cell)); } fn submit_user_message(&mut self, user_message: UserMessage) { @@ -608,7 +743,7 @@ impl ChatWidget<'_> { // Only show the text portion in conversation history. if !text.is_empty() { - self.add_to_history(&history_cell::new_user_prompt(text.clone())); + self.add_to_history(history_cell::new_user_prompt(text.clone())); } } @@ -636,9 +771,9 @@ impl ChatWidget<'_> { | EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent { delta, }) => self.on_agent_reasoning_delta(delta), - EventMsg::AgentReasoning(AgentReasoningEvent { text }) - | EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => { - self.on_agent_reasoning_final(text) + EventMsg::AgentReasoning(AgentReasoningEvent { .. }) + | EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => { + self.on_agent_reasoning_final() } EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(), EventMsg::TaskStarted => self.on_task_started(), @@ -663,6 +798,7 @@ impl ChatWidget<'_> { EventMsg::BackgroundEvent(BackgroundEventEvent { message }) => { self.on_background_event(message) } + EventMsg::StreamError(StreamErrorEvent { message }) => self.on_stream_error(message), } // Coalesce redraws: issue at most one after handling the event if self.needs_redraw { @@ -672,7 +808,7 @@ impl ChatWidget<'_> { } fn request_redraw(&mut self) { - self.app_event_tx.send(AppEvent::RequestRedraw); + self.frame_requester.schedule_frame(); } pub(crate) fn add_diff_in_progress(&mut self) { @@ -684,12 +820,12 @@ impl ChatWidget<'_> { pub(crate) fn add_diff_output(&mut self, diff_output: String) { self.bottom_pane.set_task_running(false); - self.add_to_history(&history_cell::new_diff_output(diff_output)); + self.add_to_history(history_cell::new_diff_output(diff_output)); self.mark_needs_redraw(); } pub(crate) fn add_status_output(&mut self) { - self.add_to_history(&history_cell::new_status_output( + self.add_to_history(history_cell::new_status_output( &self.config, &self.total_token_usage, &self.session_id, @@ -771,7 +907,7 @@ impl ChatWidget<'_> { } self.bottom_pane.show_selection_view( - "Select Approvals Mode".to_string(), + "Select Approval Mode".to_string(), None, Some("Press Enter to confirm or Esc to go back".to_string()), items, @@ -800,7 +936,7 @@ impl ChatWidget<'_> { pub(crate) fn add_mcp_output(&mut self) { if self.config.mcp_servers.is_empty() { - self.add_to_history(&history_cell::empty_mcp_output()); + self.add_to_history(history_cell::empty_mcp_output()); } else { self.submit_op(Op::ListMcpTools); } @@ -848,7 +984,7 @@ impl ChatWidget<'_> { } fn on_list_mcp_tools(&mut self, ev: McpListToolsResponseEvent) { - self.add_to_history(&history_cell::new_mcp_tools_output(&self.config, ev.tools)); + self.add_to_history(history_cell::new_mcp_tools_output(&self.config, ev.tools)); } /// Programmatically submit a user text message as if typed in the @@ -880,7 +1016,7 @@ impl ChatWidget<'_> { } } -impl WidgetRef for &ChatWidget<'_> { +impl WidgetRef for &ChatWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let [active_cell_area, bottom_pane_area] = self.layout_areas(area); (&self.bottom_pane).render(bottom_pane_area, buf); @@ -927,5 +1063,35 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU } } +// Extract the first bold (Markdown) element in the form **...** from `s`. +// Returns the inner text if found; otherwise `None`. +fn extract_first_bold(s: &str) -> Option { + let bytes = s.as_bytes(); + let mut i = 0usize; + while i + 1 < bytes.len() { + if bytes[i] == b'*' && bytes[i + 1] == b'*' { + let start = i + 2; + let mut j = start; + while j + 1 < bytes.len() { + if bytes[j] == b'*' && bytes[j + 1] == b'*' { + // Found closing ** + let inner = &s[start..j]; + let trimmed = inner.trim(); + if !trimmed.is_empty() { + return Some(trimmed.to_string()); + } else { + return None; + } + } + j += 1; + } + // No closing; stop searching (wait for more deltas) + return None; + } + i += 1; + } + None +} + #[cfg(test)] mod tests; diff --git a/codex-rs/tui/src/chatwidget/interrupts.rs b/codex-rs/tui/src/chatwidget/interrupts.rs index 40fecb72f6..531de3e646 100644 --- a/codex-rs/tui/src/chatwidget/interrupts.rs +++ b/codex-rs/tui/src/chatwidget/interrupts.rs @@ -71,7 +71,7 @@ impl InterruptManager { self.queue.push_back(QueuedInterrupt::PatchEnd(ev)); } - pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget<'_>) { + pub(crate) fn flush_all(&mut self, chat: &mut ChatWidget) { while let Some(q) = self.queue.pop_front() { match q { QueuedInterrupt::ExecApproval(id, ev) => chat.handle_exec_approval_now(id, ev), diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap index 8258ea0b28..2bc5c75e05 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__deltas_then_same_final_message_are_rendered_snapshot.snap @@ -1,10 +1,6 @@ --- source: tui/src/chatwidget/tests.rs -assertion_line: 886 expression: combined --- -thinking -I will first analyze the request. - codex Here is the result. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap index c90ec2733d..2bc5c75e05 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__final_reasoning_then_message_without_deltas_are_rendered.snap @@ -2,8 +2,5 @@ source: tui/src/chatwidget/tests.rs expression: combined --- -thinking -I will first analyze the request. - codex Here is the result. diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 3bb5d42f9f..d981f06df2 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -19,6 +19,7 @@ use codex_core::protocol::ExecCommandEndEvent; use codex_core::protocol::FileChange; use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyEndEvent; +use codex_core::protocol::StreamErrorEvent; use codex_core::protocol::TaskCompleteEvent; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; @@ -30,7 +31,6 @@ use std::io::BufRead; use std::io::BufReader; use std::io::Read; use std::path::PathBuf; -use std::sync::mpsc::channel; use tokio::sync::mpsc::unbounded_channel; fn test_config() -> Config { @@ -45,7 +45,7 @@ fn test_config() -> Config { #[test] fn final_answer_without_newline_is_flushed_immediately() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Set up a VT100 test terminal to capture ANSI visual output let width: u16 = 80; @@ -73,7 +73,7 @@ fn final_answer_without_newline_is_flushed_immediately() { }); // Drain history insertions and verify the final line is present. - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!( cells.iter().any(|lines| { let s = lines @@ -101,27 +101,36 @@ fn final_answer_without_newline_is_flushed_immediately() { #[tokio::test(flavor = "current_thread")] async fn helpers_are_available_and_do_not_panic() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let cfg = test_config(); let conversation_manager = Arc::new(ConversationManager::default()); - let mut w = ChatWidget::new(cfg, conversation_manager, tx, None, Vec::new(), false); + let mut w = ChatWidget::new( + cfg, + conversation_manager, + crate::tui::FrameRequester::test_dummy(), + tx, + None, + Vec::new(), + false, + ); // Basic construction sanity. let _ = &mut w; } // --- Helpers for tests that need direct construction and event draining --- fn make_chatwidget_manual() -> ( - ChatWidget<'static>, - std::sync::mpsc::Receiver, + ChatWidget, + tokio::sync::mpsc::UnboundedReceiver, tokio::sync::mpsc::UnboundedReceiver, ) { - let (tx_raw, rx) = channel::(); + let (tx_raw, rx) = unbounded_channel::(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::(); let cfg = test_config(); let bottom = BottomPane::new(BottomPaneParams { app_event_tx: app_event_tx.clone(), + frame_requester: crate::tui::FrameRequester::test_dummy(), has_input_focus: true, enhanced_keys_supported: false, placeholder_text: "Ask Codex to do anything".to_string(), @@ -136,24 +145,28 @@ fn make_chatwidget_manual() -> ( total_token_usage: TokenUsage::default(), last_token_usage: TokenUsage::default(), stream: StreamController::new(cfg), - last_stream_kind: None, running_commands: HashMap::new(), pending_exec_completions: Vec::new(), task_complete_pending: false, interrupts: InterruptManager::new(), needs_redraw: false, + reasoning_buffer: String::new(), + full_reasoning_buffer: String::new(), session_id: None, + frame_requester: crate::tui::FrameRequester::test_dummy(), }; (widget, rx, op_rx) } fn drain_insert_history( - rx: &std::sync::mpsc::Receiver, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, ) -> Vec>> { let mut out = Vec::new(); while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { - out.push(lines); + match ev { + AppEvent::InsertHistoryLines(lines) => out.push(lines), + AppEvent::InsertHistoryCell(cell) => out.push(cell.display_lines()), + _ => {} } } out @@ -196,7 +209,7 @@ fn open_fixture(name: &str) -> std::fs::File { #[test] fn exec_history_cell_shows_working_then_completed() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin command chat.handle_codex_event(Event { @@ -226,7 +239,7 @@ fn exec_history_cell_shows_working_then_completed() { }), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert_eq!( cells.len(), 1, @@ -241,7 +254,7 @@ fn exec_history_cell_shows_working_then_completed() { #[test] fn exec_history_cell_shows_working_then_failed() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin command chat.handle_codex_event(Event { @@ -271,7 +284,7 @@ fn exec_history_cell_shows_working_then_failed() { }), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert_eq!( cells.len(), 1, @@ -286,7 +299,7 @@ fn exec_history_cell_shows_working_then_failed() { #[tokio::test(flavor = "current_thread")] async fn binary_size_transcript_matches_ideal_fixture() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Set up a VT100 test terminal to capture ANSI visual output let width: u16 = 80; @@ -327,13 +340,25 @@ async fn binary_size_transcript_matches_ideal_fixture() { let ev: Event = serde_json::from_value(payload.clone()).expect("parse"); chat.handle_codex_event(ev); while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = app_ev { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + match app_ev { + AppEvent::InsertHistoryLines(lines) => { + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + AppEvent::InsertHistoryCell(cell) => { + let lines = cell.display_lines(); + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + _ => {} } } } @@ -344,13 +369,25 @@ async fn binary_size_transcript_matches_ideal_fixture() { { chat.on_commit_tick(); while let Ok(app_ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = app_ev { - transcript.push_str(&lines_to_single_string(&lines)); - crate::insert_history::insert_history_lines_to_writer( - &mut terminal, - &mut ansi, - lines, - ); + match app_ev { + AppEvent::InsertHistoryLines(lines) => { + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + AppEvent::InsertHistoryCell(cell) => { + let lines = cell.display_lines(); + transcript.push_str(&lines_to_single_string(&lines)); + crate::insert_history::insert_history_lines_to_writer( + &mut terminal, + &mut ansi, + lines, + ); + } + _ => {} } } } @@ -366,6 +403,11 @@ async fn binary_size_transcript_matches_ideal_fixture() { .expect("read ideal-binary-response.txt"); // Normalize line endings for Windows vs. Unix checkouts let ideal = ideal.replace("\r\n", "\n"); + let ideal_first_line = ideal + .lines() + .find(|l| !l.trim().is_empty()) + .unwrap_or("") + .to_string(); // Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line // and drop trailing empty lines so the shape matches the ideal fixture exactly. @@ -391,22 +433,68 @@ async fn binary_size_transcript_matches_ideal_fixture() { while lines.last().is_some_and(|l| l.is_empty()) { lines.pop(); } - // Compare only after the last session banner marker, and start at the next 'thinking' line. + // Compare only after the last session banner marker. Skip the transient + // 'thinking' header if present, and start from the first non-empty line + // of content that follows. const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in "; let last_marker_line_idx = lines .iter() .rposition(|l| l.starts_with(MARKER_PREFIX)) .expect("marker not found in visible output"); - let thinking_line_idx = (last_marker_line_idx + 1..lines.len()) - .find(|&idx| lines[idx].trim_start() == "thinking") - .expect("no 'thinking' line found after marker"); + // Anchor to the first ideal line if present; otherwise use heuristics. + let start_idx = (last_marker_line_idx + 1..lines.len()) + .find(|&idx| lines[idx].trim_start() == ideal_first_line) + .or_else(|| { + // Prefer the first assistant content line (blockquote '>' prefix) after the marker. + (last_marker_line_idx + 1..lines.len()) + .find(|&idx| lines[idx].trim_start().starts_with('>')) + }) + .unwrap_or_else(|| { + // Fallback: first non-empty, non-'thinking' line + (last_marker_line_idx + 1..lines.len()) + .find(|&idx| { + let t = lines[idx].trim_start(); + !t.is_empty() && t != "thinking" + }) + .expect("no content line found after marker") + }); let mut compare_lines: Vec = Vec::new(); - // Ensure the first line is exactly 'thinking' without leading spaces to match the fixture - compare_lines.push(lines[thinking_line_idx].trim_start().to_string()); - compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned()); + // Ensure the first line is trimmed-left to match the fixture shape. + compare_lines.push(lines[start_idx].trim_start().to_string()); + compare_lines.extend(lines[(start_idx + 1)..].iter().cloned()); let visible_after = compare_lines.join("\n"); + // Normalize: drop a leading 'thinking' line if present in either side to + // avoid coupling to whether the reasoning header is rendered in history. + fn drop_leading_thinking(s: &str) -> String { + let mut it = s.lines(); + let first = it.next(); + let rest = it.collect::>().join("\n"); + if first.is_some_and(|l| l.trim() == "thinking") { + rest + } else { + s.to_string() + } + } + let visible_after = drop_leading_thinking(&visible_after); + let ideal = drop_leading_thinking(&ideal); + + // Normalize: strip leading Markdown blockquote markers ('>' or '> ') which + // may be present in rendered transcript lines but not in the ideal text. + fn strip_blockquotes(s: &str) -> String { + s.lines() + .map(|l| { + l.strip_prefix("> ") + .or_else(|| l.strip_prefix('>')) + .unwrap_or(l) + }) + .collect::>() + .join("\n") + } + let visible_after = strip_blockquotes(&visible_after); + let ideal = strip_blockquotes(&ideal); + // Optionally update the fixture when env var is set if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") { let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR")); @@ -423,7 +511,7 @@ async fn binary_size_transcript_matches_ideal_fixture() { #[test] fn apply_patch_events_emit_history_cells() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // 1) Approval request -> proposed patch summary cell let mut changes = HashMap::new(); @@ -443,7 +531,7 @@ fn apply_patch_events_emit_history_cells() { id: "s1".into(), msg: EventMsg::ApplyPatchApprovalRequest(ev), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected pending patch cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -468,7 +556,7 @@ fn apply_patch_events_emit_history_cells() { id: "s1".into(), msg: EventMsg::PatchApplyBegin(begin), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected applying patch cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -487,7 +575,7 @@ fn apply_patch_events_emit_history_cells() { id: "s1".into(), msg: EventMsg::PatchApplyEnd(end), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected applied patch cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -498,7 +586,7 @@ fn apply_patch_events_emit_history_cells() { #[test] fn apply_patch_approval_sends_op_with_submission_id() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Simulate receiving an approval request with a distinct submission id and call id let mut changes = HashMap::new(); changes.insert( @@ -539,7 +627,7 @@ fn apply_patch_approval_sends_op_with_submission_id() { #[test] fn apply_patch_full_flow_integration_like() { - let (mut chat, rx, mut op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(); // 1) Backend requests approval let mut changes = HashMap::new(); @@ -655,7 +743,7 @@ fn apply_patch_untrusted_shows_approval_modal() { #[test] fn apply_patch_request_shows_diff_summary() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Ensure we are in OnRequest so an approval is surfaced chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest; @@ -680,7 +768,7 @@ fn apply_patch_request_shows_diff_summary() { }); // Drain history insertions and verify the diff summary is present - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!( !cells.is_empty(), "expected a history cell with the proposed patch summary" @@ -702,7 +790,7 @@ fn apply_patch_request_shows_diff_summary() { #[test] fn plan_update_renders_history_cell() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); let update = UpdatePlanArgs { explanation: Some("Adapting plan".to_string()), plan: vec![ @@ -724,7 +812,7 @@ fn plan_update_renders_history_cell() { id: "sub-1".into(), msg: EventMsg::PlanUpdate(update), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected plan update cell to be sent"); let blob = lines_to_single_string(cells.last().unwrap()); assert!( @@ -737,8 +825,27 @@ fn plan_update_renders_history_cell() { } #[test] -fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); +fn stream_error_is_rendered_to_history() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); + let msg = "stream error: stream disconnected before completion: idle timeout waiting for SSE; retrying 1/5 in 211ms…"; + chat.handle_codex_event(Event { + id: "sub-1".into(), + msg: EventMsg::StreamError(StreamErrorEvent { + message: msg.to_string(), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert!(!cells.is_empty(), "expected a history cell for StreamError"); + let blob = lines_to_single_string(cells.last().unwrap()); + assert!(blob.contains("⚠ ")); + assert!(blob.contains("stream error:")); + assert!(blob.contains("idle timeout waiting for SSE")); +} + +#[test] +fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Answer: no header until a newline commit chat.handle_codex_event(Event { @@ -749,7 +856,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { }); let mut saw_codex_pre = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) @@ -777,7 +884,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { chat.on_commit_tick(); let mut saw_codex_post = false; while let Ok(ev) = rx.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) @@ -795,8 +902,8 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { "expected 'codex' header to be emitted after first newline commit" ); - // Reasoning: header immediately - let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual(); + // Reasoning: do NOT emit a history header; status text is updated instead + let (mut chat2, mut rx2, _op_rx2) = make_chatwidget_manual(); chat2.handle_codex_event(Event { id: "sub-b".into(), msg: EventMsg::AgentReasoningDelta(AgentReasoningDeltaEvent { @@ -805,7 +912,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { }); let mut saw_thinking = false; while let Ok(ev) = rx2.try_recv() { - if let AppEvent::InsertHistory(lines) = ev { + if let AppEvent::InsertHistoryLines(lines) = ev { let s = lines .iter() .flat_map(|l| l.spans.iter()) @@ -819,14 +926,14 @@ fn headers_emitted_on_stream_begin_for_answer_and_reasoning() { } } assert!( - saw_thinking, - "expected 'thinking' header to be emitted at stream start" + !saw_thinking, + "reasoning deltas should not emit history headers" ); } #[test] fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Begin turn chat.handle_codex_event(Event { @@ -858,7 +965,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { }), }); - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); let mut header_count = 0usize; let mut combined = String::new(); for lines in &cells { @@ -894,7 +1001,7 @@ fn multiple_agent_messages_in_single_turn_emit_multiple_headers() { #[test] fn final_reasoning_then_message_without_deltas_are_rendered() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // No deltas; only final reasoning followed by final message. chat.handle_codex_event(Event { @@ -911,7 +1018,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() { }); // Drain history and snapshot the combined visible content. - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) @@ -921,7 +1028,7 @@ fn final_reasoning_then_message_without_deltas_are_rendered() { #[test] fn deltas_then_same_final_message_are_rendered_snapshot() { - let (mut chat, rx, _op_rx) = make_chatwidget_manual(); + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(); // Stream some reasoning deltas first. chat.handle_codex_event(Event { @@ -972,7 +1079,7 @@ fn deltas_then_same_final_message_are_rendered_snapshot() { // Snapshot the combined visible content to ensure we render as expected // when deltas are followed by the identical final message. - let cells = drain_insert_history(&rx); + let cells = drain_insert_history(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) diff --git a/codex-rs/tui/src/custom_terminal.rs b/codex-rs/tui/src/custom_terminal.rs index 91153f92ed..ef642b1a17 100644 --- a/codex-rs/tui/src/custom_terminal.rs +++ b/codex-rs/tui/src/custom_terminal.rs @@ -245,7 +245,7 @@ where /// Index of the current buffer in the previous array current: usize, /// Whether the cursor is currently hidden - hidden_cursor: bool, + pub hidden_cursor: bool, /// Area of the viewport pub viewport_area: Rect, /// Last known size of the terminal. Used to detect if the internal buffers have to be resized. diff --git a/codex-rs/tui/src/diff_render.rs b/codex-rs/tui/src/diff_render.rs index aaa7617d91..48a1665957 100644 --- a/codex-rs/tui/src/diff_render.rs +++ b/codex-rs/tui/src/diff_render.rs @@ -212,7 +212,18 @@ fn render_patch_details(changes: &HashMap) -> Vec { if let Ok(patch) = diffy::Patch::from_str(unified_diff) { + let mut is_first_hunk = true; for h in patch.hunks() { + // Render a simple separator between non-contiguous hunks + // instead of diff-style @@ headers. + if !is_first_hunk { + out.push(RtLine::from(vec![ + RtSpan::raw(" "), + RtSpan::styled("⋮", style_dim()), + ])); + } + is_first_hunk = false; + let mut old_ln = h.old_range().start(); let mut new_ln = h.new_range().start(); for l in h.lines() { @@ -276,8 +287,7 @@ fn push_wrapped_diff_line( // ("+"/"-" for inserts/deletes, or a space for context lines) so alignment // stays consistent across all diff lines. let gap_after_ln = SPACES_AFTER_LINE_NUMBER.saturating_sub(ln_str.len()); - let first_prefix_cols = indent.len() + ln_str.len() + gap_after_ln; - let cont_prefix_cols = indent.len() + ln_str.len() + gap_after_ln; + let prefix_cols = indent.len() + ln_str.len() + gap_after_ln; let mut first = true; let (sign_opt, line_style) = match kind { @@ -286,16 +296,14 @@ fn push_wrapped_diff_line( DiffLineType::Context => (None, None), }; let mut lines: Vec> = Vec::new(); - while !remaining_text.is_empty() { - let prefix_cols = if first { - first_prefix_cols - } else { - cont_prefix_cols - }; + + loop { // Fit the content for the current terminal row: // compute how many columns are available after the prefix, then split // at a UTF-8 character boundary so this row's chunk fits exactly. - let available_content_cols = term_cols.saturating_sub(prefix_cols).max(1); + let available_content_cols = term_cols + .saturating_sub(if first { prefix_cols + 1 } else { prefix_cols }) + .max(1); let split_at_byte_index = remaining_text .char_indices() .nth(available_content_cols) @@ -341,6 +349,9 @@ fn push_wrapped_diff_line( } lines.push(line); } + if remaining_text.is_empty() { + break; + } } lines } @@ -430,4 +441,72 @@ mod tests { // Render into a small terminal to capture the visual layout snapshot_lines("wrap_behavior_insert", lines, DEFAULT_WRAP_COLS + 10, 8); } + + #[test] + fn ui_snapshot_single_line_replacement_counts() { + // Reproduce: one deleted line replaced by one inserted line, no extra context + let original = "# Codex CLI (Rust Implementation)\n"; + let modified = "# Codex CLI (Rust Implementation) banana\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("README.md"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = + create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + + snapshot_lines("single_line_replacement_counts", lines, 80, 8); + } + + #[test] + fn ui_snapshot_blank_context_line() { + // Ensure a hunk that includes a blank context line at the beginning is rendered visibly + let original = "\nY\n"; + let modified = "\nY changed\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = + create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + + snapshot_lines("blank_context_line", lines, 80, 10); + } + + #[test] + fn ui_snapshot_vertical_ellipsis_between_hunks() { + // Create a patch with two separate hunks to ensure we render the vertical ellipsis (⋮) + let original = + "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n"; + let modified = "line 1\nline two changed\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline nine changed\nline 10\n"; + let patch = diffy::create_patch(original, modified).to_string(); + + let mut changes: HashMap = HashMap::new(); + changes.insert( + PathBuf::from("example.txt"), + FileChange::Update { + unified_diff: patch, + move_path: None, + }, + ); + + let lines = + create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest); + + // Height is large enough to show both hunks and the separator + snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16); + } } diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 021c09e337..9f236d78d1 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1,6 +1,7 @@ use crate::diff_render::create_diff_summary; use crate::exec_command::relativize_to_home; use crate::exec_command::strip_bash_lc_and_escape; +use crate::markdown::append_markdown; use crate::slash_command::SlashCommand; use crate::text_formatting::format_and_truncate_tool_result; use base64::Engine; @@ -11,6 +12,7 @@ use codex_core::config::Config; use codex_core::plan_tool::PlanItemArg; use codex_core::plan_tool::StepStatus; use codex_core::plan_tool::UpdatePlanArgs; +use codex_core::project_doc::discover_project_doc_paths; use codex_core::protocol::FileChange; use codex_core::protocol::McpInvocation; use codex_core::protocol::SandboxPolicy; @@ -39,7 +41,7 @@ use std::time::Instant; use tracing::error; use uuid::Uuid; -#[derive(Clone)] +#[derive(Clone, Debug)] pub(crate) struct CommandOutput { pub(crate) exit_code: i32, pub(crate) stdout: String, @@ -54,9 +56,13 @@ pub(crate) enum PatchEventType { /// Represents an event to display in the conversation history. Returns its /// `Vec>` representation to make it easier to display in a /// scrollable list. -pub(crate) trait HistoryCell { +pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync { fn display_lines(&self) -> Vec>; + fn transcript_lines(&self) -> Vec> { + self.display_lines() + } + fn desired_height(&self, width: u16) -> u16 { Paragraph::new(Text::from(self.display_lines())) .wrap(Wrap { trim: false }) @@ -66,6 +72,7 @@ pub(crate) trait HistoryCell { } } +#[derive(Debug)] pub(crate) struct PlainHistoryCell { lines: Vec>, } @@ -76,6 +83,22 @@ impl HistoryCell for PlainHistoryCell { } } +#[derive(Debug)] +pub(crate) struct TranscriptOnlyHistoryCell { + lines: Vec>, +} + +impl HistoryCell for TranscriptOnlyHistoryCell { + fn display_lines(&self) -> Vec> { + Vec::new() + } + + fn transcript_lines(&self) -> Vec> { + self.lines.clone() + } +} + +#[derive(Debug)] pub(crate) struct ExecCell { pub(crate) command: Vec, pub(crate) parsed: Vec, @@ -101,6 +124,7 @@ impl WidgetRef for &ExecCell { } } +#[derive(Debug)] struct CompletedMcpToolCallWithImageOutput { _image: DynamicImage, } @@ -168,7 +192,8 @@ pub(crate) fn new_session_info( Line::from("".dim()), Line::from(format!(" /init - {}", SlashCommand::Init.description()).dim()), Line::from(format!(" /status - {}", SlashCommand::Status.description()).dim()), - Line::from(format!(" /diff - {}", SlashCommand::Diff.description()).dim()), + Line::from(format!(" /approvals - {}", SlashCommand::Approvals.description()).dim()), + Line::from(format!(" /model - {}", SlashCommand::Model.description()).dim()), Line::from("".dim()), ]; PlainHistoryCell { lines } @@ -537,6 +562,54 @@ pub(crate) fn new_status_output( sandbox_name.into(), ])); + // AGENTS.md files discovered via core's project_doc logic + let agents_list = { + match discover_project_doc_paths(config) { + Ok(paths) => { + let mut rels: Vec = Vec::new(); + for p in paths { + let display = if let Some(parent) = p.parent() { + if parent == config.cwd { + "AGENTS.md".to_string() + } else { + let mut cur = config.cwd.as_path(); + let mut ups = 0usize; + let mut reached = false; + while let Some(c) = cur.parent() { + if cur == parent { + reached = true; + break; + } + cur = c; + ups += 1; + } + if reached { + format!("{}AGENTS.md", "../".repeat(ups)) + } else if let Ok(stripped) = p.strip_prefix(&config.cwd) { + stripped.display().to_string() + } else { + p.display().to_string() + } + } + } else { + p.display().to_string() + }; + rels.push(display); + } + rels + } + Err(_) => Vec::new(), + } + }; + if agents_list.is_empty() { + lines.push(Line::from(" • AGENTS files: (none)")); + } else { + lines.push(Line::from(vec![ + " • AGENTS files: ".into(), + agents_list.join(", ").into(), + ])); + } + lines.push(Line::from("")); // 👤 Account (only if ChatGPT tokens exist), shown under the first block @@ -641,6 +714,15 @@ pub(crate) fn empty_mcp_output() -> PlainHistoryCell { Line::from(vec!["🔌 ".into(), "MCP Tools".bold()]), Line::from(""), Line::from(" • No MCP servers configured.".italic()), + Line::from(vec![ + " See the ".into(), + Span::styled( + "\u{1b}]8;;https://github.com/openai/codex/blob/main/codex-rs/config.md#mcp_servers\u{7}MCP docs\u{1b}]8;;\u{7}", + Style::default().add_modifier(Modifier::UNDERLINED), + ), + " to configure them.".into(), + ]) + .style(Style::default().add_modifier(Modifier::DIM)), Line::from(""), ]; @@ -718,6 +800,12 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +pub(crate) fn new_stream_error_event(message: String) -> PlainHistoryCell { + let lines: Vec> = + vec![vec!["⚠ ".magenta().bold(), message.dim()].into(), "".into()]; + PlainHistoryCell { lines } +} + /// Render a user‑friendly plan update styled like a checkbox todo list. pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell { let UpdatePlanArgs { explanation, plan } = update; @@ -920,6 +1008,17 @@ pub(crate) fn new_patch_apply_success(stdout: String) -> PlainHistoryCell { PlainHistoryCell { lines } } +pub(crate) fn new_reasoning_block( + full_reasoning_buffer: String, + config: &Config, +) -> TranscriptOnlyHistoryCell { + let mut lines: Vec> = Vec::new(); + lines.push(Line::from("thinking".magenta().italic())); + append_markdown(&full_reasoning_buffer, &mut lines, config); + lines.push(Line::from("")); + TranscriptOnlyHistoryCell { lines } +} + fn output_lines( output: Option<&CommandOutput>, only_err: bool, diff --git a/codex-rs/tui/src/insert_history.rs b/codex-rs/tui/src/insert_history.rs index ced667af8d..b638870935 100644 --- a/codex-rs/tui/src/insert_history.rs +++ b/codex-rs/tui/src/insert_history.rs @@ -22,7 +22,7 @@ use textwrap::Options as TwOptions; use textwrap::WordSplitter; /// Insert `lines` above the viewport. -pub(crate) fn insert_history_lines(terminal: &mut tui::Tui, lines: Vec) { +pub(crate) fn insert_history_lines(terminal: &mut tui::Terminal, lines: Vec) { let mut out = std::io::stdout(); insert_history_lines_to_writer(terminal, &mut out, lines); } @@ -38,9 +38,8 @@ pub fn insert_history_lines_to_writer( W: Write, { let screen_size = terminal.backend().size().unwrap_or(Size::new(0, 0)); - let cursor_pos = terminal.get_cursor_position().ok(); - let mut area = terminal.get_frame().area(); + let mut area = terminal.viewport_area; // Pre-wrap lines using word-aware wrapping so terminal scrollback sees the same // formatting as the TUI. This avoids character-level hard wrapping by the terminal. @@ -104,9 +103,14 @@ pub fn insert_history_lines_to_writer( queue!(writer, ResetScrollRegion).ok(); // Restore the cursor position to where it was before we started. - if let Some(cursor_pos) = cursor_pos { - queue!(writer, MoveTo(cursor_pos.x, cursor_pos.y)).ok(); - } + queue!( + writer, + MoveTo( + terminal.last_known_cursor_pos.x, + terminal.last_known_cursor_pos.y + ) + ) + .ok(); } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index 0f8b2242cb..454a7f3ead 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,6 +49,7 @@ mod slash_command; mod status_indicator_widget; mod streaming; mod text_formatting; +mod transcript_app; mod tui; mod user_approval_widget; @@ -64,6 +65,11 @@ use color_eyre::owo_colors::OwoColorize; pub use cli::Cli; +use crate::onboarding::TrustDirectorySelection; +use crate::onboarding::onboarding_screen::OnboardingScreenArgs; +use crate::onboarding::onboarding_screen::run_onboarding_app; +use crate::tui::Tui; + // (tests access modules directly within the crate) pub async fn run_main( @@ -214,47 +220,17 @@ pub async fn run_main( let _ = tracing_subscriber::registry().with(file_layer).try_init(); - #[allow(clippy::print_stderr)] - #[cfg(not(debug_assertions))] - if let Some(latest_version) = updates::get_upgrade_version(&config) { - let current_version = env!("CARGO_PKG_VERSION"); - let exe = std::env::current_exe()?; - let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); - - eprintln!( - "{} {current_version} -> {latest_version}.", - "✨⬆️ Update available!".bold().cyan() - ); - - if managed_by_npm { - let npm_cmd = "npm install -g @openai/codex@latest"; - eprintln!("Run {} to update.", npm_cmd.cyan().on_black()); - } else if cfg!(target_os = "macos") - && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) - { - let brew_cmd = "brew upgrade codex"; - eprintln!("Run {} to update.", brew_cmd.cyan().on_black()); - } else { - eprintln!( - "See {} for the latest releases and installation options.", - "https://github.com/openai/codex/releases/latest" - .cyan() - .on_black() - ); - } - - eprintln!(""); - } - run_ratatui_app(cli, config, should_show_trust_screen) + .await .map_err(|err| std::io::Error::other(err.to_string())) } -fn run_ratatui_app( +async fn run_ratatui_app( cli: Cli, config: Config, should_show_trust_screen: bool, ) -> color_eyre::Result { + let mut config = config; color_eyre::install()?; // Forward panic reports through tracing so they appear in the UI status @@ -266,23 +242,92 @@ fn run_ratatui_app( tracing::error!("panic: {info}"); prev_hook(info); })); - let mut terminal = tui::init(&config)?; + let mut terminal = tui::init()?; terminal.clear()?; + let mut tui = Tui::new(terminal); + + // Show update banner in terminal history (instead of stderr) so it is visible + // within the TUI scrollback. Building spans keeps styling consistent. + #[cfg(not(debug_assertions))] + if let Some(latest_version) = updates::get_upgrade_version(&config) { + use ratatui::style::Stylize as _; + use ratatui::text::Line; + use ratatui::text::Span; + + let current_version = env!("CARGO_PKG_VERSION"); + let exe = std::env::current_exe()?; + let managed_by_npm = std::env::var_os("CODEX_MANAGED_BY_NPM").is_some(); + + let mut lines: Vec> = Vec::new(); + lines.push(Line::from(vec![ + "✨⬆️ Update available!".bold().cyan(), + Span::raw(" "), + Span::raw(format!("{current_version} -> {latest_version}.")), + ])); + + if managed_by_npm { + let npm_cmd = "npm install -g @openai/codex@latest"; + lines.push(Line::from(vec![ + Span::raw("Run "), + npm_cmd.cyan(), + Span::raw(" to update."), + ])); + } else if cfg!(target_os = "macos") + && (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local")) + { + let brew_cmd = "brew upgrade codex"; + lines.push(Line::from(vec![ + Span::raw("Run "), + brew_cmd.cyan(), + Span::raw(" to update."), + ])); + } else { + lines.push(Line::from(vec![ + Span::raw("See "), + "https://github.com/openai/codex/releases/latest".cyan(), + Span::raw(" for the latest releases and installation options."), + ])); + } + + lines.push(Line::from("")); + tui.insert_history_lines(lines); + } + // Initialize high-fidelity session event logging if enabled. session_log::maybe_init(&config); let Cli { prompt, images, .. } = cli; - let mut app = App::new(config.clone(), prompt, images, should_show_trust_screen); - let app_result = app.run(&mut terminal); - let usage = app.token_usage(); + let login_status = get_login_status(&config); + let should_show_onboarding = + should_show_onboarding(login_status, &config, should_show_trust_screen); + if should_show_onboarding { + let directory_trust_decision = run_onboarding_app( + OnboardingScreenArgs { + codex_home: config.codex_home.clone(), + cwd: config.cwd.clone(), + show_login_screen: should_show_login_screen(login_status, &config), + show_trust_screen: should_show_trust_screen, + login_status, + preferred_auth_method: config.preferred_auth_method, + }, + &mut tui, + ) + .await?; + if let Some(TrustDirectorySelection::Trust) = directory_trust_decision { + config.approval_policy = AskForApproval::OnRequest; + config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); + } + } + + let app_result = App::run(&mut tui, config, prompt, images).await; restore(); // Mark the end of the recorded session. session_log::log_session_end(); // ignore error when collecting usage – report underlying error instead - app_result.map(|_| usage) + app_result } #[expect( @@ -356,3 +401,80 @@ fn determine_repo_trust_state( Ok(true) } } + +fn should_show_onboarding( + login_status: LoginStatus, + config: &Config, + show_trust_screen: bool, +) -> bool { + if show_trust_screen { + return true; + } + + should_show_login_screen(login_status, config) +} + +fn should_show_login_screen(login_status: LoginStatus, config: &Config) -> bool { + // Only show the login screen for providers that actually require OpenAI auth + // (OpenAI or equivalents). For OSS/other providers, skip login entirely. + if !config.model_provider.requires_openai_auth { + return false; + } + + match login_status { + LoginStatus::NotAuthenticated => true, + LoginStatus::AuthMode(method) => method != config.preferred_auth_method, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_config(preferred: AuthMode) -> Config { + let mut cfg = Config::load_from_base_config_with_overrides( + ConfigToml::default(), + ConfigOverrides::default(), + std::env::temp_dir(), + ) + .expect("load default config"); + cfg.preferred_auth_method = preferred; + cfg + } + + #[test] + fn shows_login_when_not_authenticated() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(should_show_login_screen( + LoginStatus::NotAuthenticated, + &cfg + )); + } + + #[test] + fn shows_login_when_api_key_but_prefers_chatgpt() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ApiKey), + &cfg + )) + } + + #[test] + fn hides_login_when_api_key_and_prefers_api_key() { + let cfg = make_config(AuthMode::ApiKey); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ApiKey), + &cfg + )) + } + + #[test] + fn hides_login_when_chatgpt_and_prefers_chatgpt() { + let cfg = make_config(AuthMode::ChatGPT); + assert!(!should_show_login_screen( + LoginStatus::AuthMode(AuthMode::ChatGPT), + &cfg + )) + } +} diff --git a/codex-rs/tui/src/markdown_stream.rs b/codex-rs/tui/src/markdown_stream.rs index 29fcbc8c4d..af928cf8e1 100644 --- a/codex-rs/tui/src/markdown_stream.rs +++ b/codex-rs/tui/src/markdown_stream.rs @@ -45,22 +45,6 @@ impl MarkdownStreamCollector { self.buffer.push_str(delta); } - /// Insert a paragraph/section separator if one is not already present at the - /// end of the buffer. Ensures the next content starts after a blank line. - pub fn insert_section_break(&mut self) { - if self.buffer.is_empty() { - return; - } - if self.buffer.ends_with("\n\n") { - return; - } - if self.buffer.ends_with('\n') { - self.buffer.push('\n'); - } else { - self.buffer.push_str("\n\n"); - } - } - /// 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. diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index 2ef70ca275..6f653cd0f9 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1,3 +1,5 @@ +#![allow(clippy::unwrap_used)] + use codex_login::CLIENT_ID; use codex_login::ServerOptions; use codex_login::ShutdownHandle; @@ -18,19 +20,19 @@ use ratatui::widgets::WidgetRef; use ratatui::widgets::Wrap; use codex_login::AuthMode; +use std::sync::RwLock; use crate::LoginStatus; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; use std::path::PathBuf; +use std::sync::Arc; use super::onboarding_screen::StepState; -// no additional imports -#[derive(Debug)] +#[derive(Clone)] pub(crate) enum SignInState { PickMode, ChatGptContinueInBrowser(ContinueInBrowserState), @@ -40,18 +42,17 @@ pub(crate) enum SignInState { EnvVarFound, } -#[derive(Debug)] +#[derive(Clone)] /// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up. pub(crate) struct ContinueInBrowserState { auth_url: String, - shutdown_handle: Option, - _login_wait_handle: Option>, + shutdown_flag: Option, } impl Drop for ContinueInBrowserState { fn drop(&mut self) { - if let Some(flag) = &self.shutdown_handle { - flag.shutdown(); + if let Some(handle) = &self.shutdown_flag { + handle.shutdown(); } } } @@ -69,20 +70,32 @@ impl KeyboardHandler for AuthModeWidget { self.start_chatgpt_login(); } KeyCode::Char('2') => self.verify_api_key(), - KeyCode::Enter => match self.sign_in_state { - SignInState::PickMode => match self.highlighted_mode { - AuthMode::ChatGPT => self.start_chatgpt_login(), - AuthMode::ApiKey => self.verify_api_key(), - }, - SignInState::EnvVarMissing => self.sign_in_state = SignInState::PickMode, - SignInState::ChatGptSuccessMessage => { - self.sign_in_state = SignInState::ChatGptSuccess + KeyCode::Enter => { + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + match sign_in_state { + SignInState::PickMode => match self.highlighted_mode { + AuthMode::ChatGPT => { + self.start_chatgpt_login(); + } + AuthMode::ApiKey => { + self.verify_api_key(); + } + }, + SignInState::EnvVarMissing => { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + } + SignInState::ChatGptSuccessMessage => { + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + } + _ => {} } - _ => {} - }, + } KeyCode::Esc => { - if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { - self.sign_in_state = SignInState::PickMode; + tracing::info!("Esc pressed"); + let sign_in_state = { (*self.sign_in_state.read().unwrap()).clone() }; + if matches!(sign_in_state, SignInState::ChatGptContinueInBrowser(_)) { + *self.sign_in_state.write().unwrap() = SignInState::PickMode; + self.request_frame.schedule_frame(); } } _ => {} @@ -90,12 +103,12 @@ impl KeyboardHandler for AuthModeWidget { } } -#[derive(Debug)] +#[derive(Clone)] pub(crate) struct AuthModeWidget { - pub event_tx: AppEventSender, + pub request_frame: FrameRequester, pub highlighted_mode: AuthMode, pub error: Option, - pub sign_in_state: SignInState, + pub sign_in_state: Arc>, pub codex_home: PathBuf, pub login_status: LoginStatus, pub preferred_auth_method: AuthMode, @@ -215,14 +228,13 @@ impl AuthModeWidget { fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) { let mut spans = vec![Span::from("> ")]; // Schedule a follow-up frame to keep the shimmer animation going. - self.event_tx - .send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis( - 100, - ))); + self.request_frame + .schedule_frame_in(std::time::Duration::from_millis(100)); spans.extend(shimmer_spans("Finish signing in via your browser")); let mut lines = vec![Line::from(spans), Line::from("")]; - if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state + let sign_in_state = self.sign_in_state.read().unwrap(); + if let SignInState::ChatGptContinueInBrowser(state) = &*sign_in_state && !state.auth_url.is_empty() { lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:")); @@ -315,35 +327,45 @@ impl AuthModeWidget { // If we're already authenticated with ChatGPT, don't start a new login – // just proceed to the success message flow. if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ChatGPT)) { - self.sign_in_state = SignInState::ChatGptSuccess; - self.event_tx.send(AppEvent::RequestRedraw); + *self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess; + self.request_frame.schedule_frame(); return; } self.error = None; let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string()); - let server = run_login_server(opts); - match server { + match run_login_server(opts) { Ok(child) => { - let auth_url = child.auth_url.clone(); - let shutdown_handle = child.cancel_handle(); - - let event_tx = self.event_tx.clone(); - let join_handle = tokio::spawn(async move { - spawn_completion_poller(child, event_tx).await; + let sign_in_state = self.sign_in_state.clone(); + let request_frame = self.request_frame.clone(); + tokio::spawn(async move { + let auth_url = child.auth_url.clone(); + { + *sign_in_state.write().unwrap() = + SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { + auth_url, + shutdown_flag: Some(child.cancel_handle()), + }); + } + request_frame.schedule_frame(); + let r = child.block_until_done().await; + match r { + Ok(()) => { + *sign_in_state.write().unwrap() = SignInState::ChatGptSuccessMessage; + request_frame.schedule_frame(); + } + _ => { + *sign_in_state.write().unwrap() = SignInState::PickMode; + // self.error = Some(e.to_string()); + request_frame.schedule_frame(); + } + } }); - self.sign_in_state = - SignInState::ChatGptContinueInBrowser(ContinueInBrowserState { - auth_url, - shutdown_handle: Some(shutdown_handle), - _login_wait_handle: Some(join_handle), - }); - self.event_tx.send(AppEvent::RequestRedraw); } Err(e) => { - self.sign_in_state = SignInState::PickMode; + *self.sign_in_state.write().unwrap() = SignInState::PickMode; self.error = Some(e.to_string()); - self.event_tx.send(AppEvent::RequestRedraw); + self.request_frame.schedule_frame(); } } } @@ -353,33 +375,18 @@ impl AuthModeWidget { if matches!(self.login_status, LoginStatus::AuthMode(AuthMode::ApiKey)) { // We already have an API key configured (e.g., from auth.json or env), // so mark this step complete immediately. - self.sign_in_state = SignInState::EnvVarFound; + *self.sign_in_state.write().unwrap() = SignInState::EnvVarFound; } else { - self.sign_in_state = SignInState::EnvVarMissing; + *self.sign_in_state.write().unwrap() = SignInState::EnvVarMissing; } - - self.event_tx.send(AppEvent::RequestRedraw); + self.request_frame.schedule_frame(); } } -async fn spawn_completion_poller( - child: codex_login::LoginServer, - event_tx: AppEventSender, -) -> tokio::task::JoinHandle<()> { - tokio::spawn(async move { - if let Ok(()) = child.block_until_done().await { - event_tx.send(AppEvent::OnboardingAuthComplete(Ok(()))); - } else { - event_tx.send(AppEvent::OnboardingAuthComplete(Err( - "login failed".to_string() - ))); - } - }) -} - impl StepStateProvider for AuthModeWidget { fn get_step_state(&self) -> StepState { - match &self.sign_in_state { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { SignInState::PickMode | SignInState::EnvVarMissing | SignInState::ChatGptContinueInBrowser(_) @@ -391,7 +398,8 @@ impl StepStateProvider for AuthModeWidget { impl WidgetRef for AuthModeWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { - match self.sign_in_state { + let sign_in_state = self.sign_in_state.read().unwrap(); + match &*sign_in_state { SignInState::PickMode => { self.render_pick_mode(area, buf); } diff --git a/codex-rs/tui/src/onboarding/continue_to_chat.rs b/codex-rs/tui/src/onboarding/continue_to_chat.rs deleted file mode 100644 index 01e31d900a..0000000000 --- a/codex-rs/tui/src/onboarding/continue_to_chat.rs +++ /dev/null @@ -1,34 +0,0 @@ -use ratatui::buffer::Buffer; -use ratatui::layout::Rect; -use ratatui::widgets::WidgetRef; - -use crate::app::ChatWidgetArgs; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; -use crate::onboarding::onboarding_screen::StepStateProvider; - -use super::onboarding_screen::StepState; -use std::sync::Arc; -use std::sync::Mutex; - -/// This doesn't render anything explicitly but serves as a signal that we made it to the end and -/// we should continue to the chat. -pub(crate) struct ContinueToChatWidget { - pub event_tx: AppEventSender, - pub chat_widget_args: Arc>, -} - -impl StepStateProvider for ContinueToChatWidget { - fn get_step_state(&self) -> StepState { - StepState::Complete - } -} - -impl WidgetRef for &ContinueToChatWidget { - fn render_ref(&self, _area: Rect, _buf: &mut Buffer) { - if let Ok(args) = self.chat_widget_args.lock() { - self.event_tx - .send(AppEvent::OnboardingComplete(args.clone())); - } - } -} diff --git a/codex-rs/tui/src/onboarding/mod.rs b/codex-rs/tui/src/onboarding/mod.rs index c116936851..d4cfd6d1f4 100644 --- a/codex-rs/tui/src/onboarding/mod.rs +++ b/codex-rs/tui/src/onboarding/mod.rs @@ -1,5 +1,5 @@ mod auth; -mod continue_to_chat; pub mod onboarding_screen; mod trust_directory; +pub use trust_directory::TrustDirectorySelection; mod welcome; diff --git a/codex-rs/tui/src/onboarding/onboarding_screen.rs b/codex-rs/tui/src/onboarding/onboarding_screen.rs index ea473512d0..5721430cd0 100644 --- a/codex-rs/tui/src/onboarding/onboarding_screen.rs +++ b/codex-rs/tui/src/onboarding/onboarding_screen.rs @@ -1,5 +1,7 @@ use codex_core::util::is_inside_git_repo; +use crossterm::event::KeyCode; use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use ratatui::buffer::Buffer; use ratatui::layout::Rect; use ratatui::prelude::Widget; @@ -9,25 +11,24 @@ use ratatui::widgets::WidgetRef; use codex_login::AuthMode; use crate::LoginStatus; -use crate::app::ChatWidgetArgs; -use crate::app_event::AppEvent; -use crate::app_event_sender::AppEventSender; use crate::onboarding::auth::AuthModeWidget; use crate::onboarding::auth::SignInState; -use crate::onboarding::continue_to_chat::ContinueToChatWidget; use crate::onboarding::trust_directory::TrustDirectorySelection; use crate::onboarding::trust_directory::TrustDirectoryWidget; use crate::onboarding::welcome::WelcomeWidget; +use crate::tui::FrameRequester; +use crate::tui::Tui; +use crate::tui::TuiEvent; +use color_eyre::eyre::Result; use std::path::PathBuf; use std::sync::Arc; -use std::sync::Mutex; +use std::sync::RwLock; #[allow(clippy::large_enum_variant)] enum Step { Welcome(WelcomeWidget), Auth(AuthModeWidget), TrustDirectory(TrustDirectoryWidget), - ContinueToChat(ContinueToChatWidget), } pub(crate) trait KeyboardHandler { @@ -45,43 +46,42 @@ pub(crate) trait StepStateProvider { } pub(crate) struct OnboardingScreen { - event_tx: AppEventSender, + request_frame: FrameRequester, steps: Vec, + is_done: bool, } pub(crate) struct OnboardingScreenArgs { - pub event_tx: AppEventSender, - pub chat_widget_args: ChatWidgetArgs, pub codex_home: PathBuf, pub cwd: PathBuf, pub show_trust_screen: bool, pub show_login_screen: bool, pub login_status: LoginStatus, + pub preferred_auth_method: AuthMode, } impl OnboardingScreen { - pub(crate) fn new(args: OnboardingScreenArgs) -> Self { + pub(crate) fn new(tui: &mut Tui, args: OnboardingScreenArgs) -> Self { let OnboardingScreenArgs { - event_tx, - chat_widget_args, codex_home, cwd, show_trust_screen, show_login_screen, login_status, + preferred_auth_method, } = args; let mut steps: Vec = vec![Step::Welcome(WelcomeWidget { is_logged_in: !matches!(login_status, LoginStatus::NotAuthenticated), })]; if show_login_screen { steps.push(Step::Auth(AuthModeWidget { - event_tx: event_tx.clone(), + request_frame: tui.frame_requester(), highlighted_mode: AuthMode::ChatGPT, error: None, - sign_in_state: SignInState::PickMode, + sign_in_state: Arc::new(RwLock::new(SignInState::PickMode)), codex_home: codex_home.clone(), login_status, - preferred_auth_method: chat_widget_args.config.preferred_auth_method, + preferred_auth_method, })) } let is_git_repo = is_inside_git_repo(&cwd); @@ -91,9 +91,6 @@ impl OnboardingScreen { // Default to not trusting the directory if it's not a git repo. TrustDirectorySelection::DontTrust }; - // Share ChatWidgetArgs between steps so changes in the TrustDirectory step - // are reflected when continuing to chat. - let shared_chat_args = Arc::new(Mutex::new(chat_widget_args)); if show_trust_screen { steps.push(Step::TrustDirectory(TrustDirectoryWidget { cwd, @@ -102,39 +99,13 @@ impl OnboardingScreen { selection: None, highlighted, error: None, - chat_widget_args: shared_chat_args.clone(), })) } - steps.push(Step::ContinueToChat(ContinueToChatWidget { - event_tx: event_tx.clone(), - chat_widget_args: shared_chat_args, - })); // TODO: add git warning. - Self { event_tx, steps } - } - - pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) { - let current_step = self.current_step_mut(); - if let Some(Step::Auth(state)) = current_step { - match result { - Ok(()) => { - state.sign_in_state = SignInState::ChatGptSuccessMessage; - self.event_tx.send(AppEvent::RequestRedraw); - let tx1 = self.event_tx.clone(); - let tx2 = self.event_tx.clone(); - std::thread::spawn(move || { - std::thread::sleep(std::time::Duration::from_millis(150)); - tx1.send(AppEvent::RequestRedraw); - std::thread::sleep(std::time::Duration::from_millis(200)); - tx2.send(AppEvent::RequestRedraw); - }); - } - Err(e) => { - state.sign_in_state = SignInState::PickMode; - state.error = Some(e); - self.event_tx.send(AppEvent::RequestRedraw); - } - } + Self { + request_frame: tui.frame_requester(), + steps, + is_done: false, } } @@ -168,19 +139,57 @@ impl OnboardingScreen { out } - fn current_step_mut(&mut self) -> Option<&mut Step> { + pub(crate) fn is_done(&self) -> bool { + self.is_done + || !self + .steps + .iter() + .any(|step| matches!(step.get_step_state(), StepState::InProgress)) + } + + pub fn directory_trust_decision(&self) -> Option { self.steps - .iter_mut() - .find(|step| matches!(step.get_step_state(), StepState::InProgress)) + .iter() + .find_map(|step| { + if let Step::TrustDirectory(TrustDirectoryWidget { selection, .. }) = step { + Some(*selection) + } else { + None + } + }) + .flatten() } } impl KeyboardHandler for OnboardingScreen { fn handle_key_event(&mut self, key_event: KeyEvent) { - if let Some(active_step) = self.current_steps_mut().into_iter().last() { - active_step.handle_key_event(key_event); - } - self.event_tx.send(AppEvent::RequestRedraw); + match key_event { + KeyEvent { + code: KeyCode::Char('d'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('q'), + kind: KeyEventKind::Press, + .. + } => { + self.is_done = true; + } + _ => { + if let Some(active_step) = self.current_steps_mut().into_iter().last() { + active_step.handle_key_event(key_event); + } + } + }; + self.request_frame.schedule_frame(); } } @@ -246,7 +255,7 @@ impl WidgetRef for &OnboardingScreen { impl KeyboardHandler for Step { fn handle_key_event(&mut self, key_event: KeyEvent) { match self { - Step::Welcome(_) | Step::ContinueToChat(_) => (), + Step::Welcome(_) => (), Step::Auth(widget) => widget.handle_key_event(key_event), Step::TrustDirectory(widget) => widget.handle_key_event(key_event), } @@ -259,7 +268,6 @@ impl StepStateProvider for Step { Step::Welcome(w) => w.get_step_state(), Step::Auth(w) => w.get_step_state(), Step::TrustDirectory(w) => w.get_step_state(), - Step::ContinueToChat(w) => w.get_step_state(), } } } @@ -276,9 +284,39 @@ impl WidgetRef for Step { Step::TrustDirectory(widget) => { widget.render_ref(area, buf); } - Step::ContinueToChat(widget) => { - widget.render_ref(area, buf); - } } } } + +pub(crate) async fn run_onboarding_app( + args: OnboardingScreenArgs, + tui: &mut Tui, +) -> Result> { + use tokio_stream::StreamExt; + + let mut onboarding_screen = OnboardingScreen::new(tui, args); + + tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&onboarding_screen, frame.area()); + })?; + + let tui_events = tui.event_stream(); + tokio::pin!(tui_events); + + while !onboarding_screen.is_done() { + if let Some(event) = tui_events.next().await { + match event { + TuiEvent::Key(key_event) => { + onboarding_screen.handle_key_event(key_event); + } + TuiEvent::Draw => { + let _ = tui.draw(u16::MAX, |frame| { + frame.render_widget_ref(&onboarding_screen, frame.area()); + }); + } + _ => {} + } + } + } + Ok(onboarding_screen.directory_trust_decision()) +} diff --git a/codex-rs/tui/src/onboarding/trust_directory.rs b/codex-rs/tui/src/onboarding/trust_directory.rs index 84f7aef8eb..7e41ae0506 100644 --- a/codex-rs/tui/src/onboarding/trust_directory.rs +++ b/codex-rs/tui/src/onboarding/trust_directory.rs @@ -1,8 +1,6 @@ use std::path::PathBuf; use codex_core::config::set_project_trusted; -use codex_core::protocol::AskForApproval; -use codex_core::protocol::SandboxPolicy; use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use ratatui::buffer::Buffer; @@ -22,9 +20,6 @@ use crate::onboarding::onboarding_screen::KeyboardHandler; use crate::onboarding::onboarding_screen::StepStateProvider; use super::onboarding_screen::StepState; -use crate::app::ChatWidgetArgs; -use std::sync::Arc; -use std::sync::Mutex; pub(crate) struct TrustDirectoryWidget { pub codex_home: PathBuf, @@ -33,11 +28,10 @@ pub(crate) struct TrustDirectoryWidget { pub selection: Option, pub highlighted: TrustDirectorySelection, pub error: Option, - pub chat_widget_args: Arc>, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub(crate) enum TrustDirectorySelection { +pub enum TrustDirectorySelection { Trust, DontTrust, } @@ -156,13 +150,6 @@ impl TrustDirectoryWidget { // self.error = Some("Failed to set project trusted".to_string()); } - // Update the in-memory chat config for this session to a more permissive - // policy suitable for a trusted workspace. - if let Ok(mut args) = self.chat_widget_args.lock() { - args.config.approval_policy = AskForApproval::OnRequest; - args.config.sandbox_policy = SandboxPolicy::new_workspace_write_policy(); - } - self.selection = Some(TrustDirectorySelection::Trust); } diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index e94f58436f..4937be8b5f 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -132,35 +132,16 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { AppEvent::CodexEvent(ev) => { write_record("to_tui", "codex_event", ev); } - AppEvent::KeyEvent(k) => { + AppEvent::NewSession => { let value = json!({ "ts": now_ts(), "dir": "to_tui", - "kind": "key_event", - "event": format!("{:?}", k), - }); - LOGGER.write_json_line(value); - } - AppEvent::Paste(s) => { - let value = json!({ - "ts": now_ts(), - "dir": "to_tui", - "kind": "paste", - "text": s, - }); - LOGGER.write_json_line(value); - } - AppEvent::DispatchCommand(cmd) => { - let value = json!({ - "ts": now_ts(), - "dir": "to_tui", - "kind": "slash_command", - "command": format!("{:?}", cmd), + "kind": "new_session", }); LOGGER.write_json_line(value); } // Internal UI events; still log for fidelity, but avoid heavy payloads. - AppEvent::InsertHistory(lines) => { + AppEvent::InsertHistoryLines(lines) => { let value = json!({ "ts": now_ts(), "dir": "to_tui", @@ -169,6 +150,15 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::InsertHistoryCell(cell) => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "insert_history_cell", + "lines": cell.transcript_lines().len(), + }); + LOGGER.write_json_line(value); + } AppEvent::StartFileSearch(query) => { let value = json!({ "ts": now_ts(), diff --git a/codex-rs/tui/src/shimmer.rs b/codex-rs/tui/src/shimmer.rs index 2523f6eccf..b5752fc3d8 100644 --- a/codex-rs/tui/src/shimmer.rs +++ b/codex-rs/tui/src/shimmer.rs @@ -22,7 +22,7 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { // Use time-based sweep synchronized to process start. let padding = 10usize; let period = chars.len() + padding * 2; - let sweep_seconds = 2.5f32; + let sweep_seconds = 2.0f32; let pos_f = (elapsed_since_start().as_secs_f32() % sweep_seconds) / sweep_seconds * (period as f32); let pos = pos_f as usize; @@ -63,9 +63,11 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec> { } fn color_for_level(level: u8) -> Style { - if level < 144 { + // Tune thresholds so the edges of the shimmer band appear dim + // in fallback mode (no true color support). + if level < 160 { Style::default().add_modifier(Modifier::DIM) - } else if level < 208 { + } else if level < 224 { Style::default() } else { Style::default().add_modifier(Modifier::BOLD) diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index 13ce2ff0fa..6311661b28 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -38,7 +38,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", SlashCommand::Status => "show current session configuration and token usage", - SlashCommand::Model => "choose a model preset (model + reasoning effort)", + SlashCommand::Model => "choose what model and reasoning effort to use", SlashCommand::Approvals => "choose what Codex can do without approval", SlashCommand::Mcp => "list configured MCP tools", SlashCommand::Logout => "log out of Codex", diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap new file mode 100644 index 0000000000..b38704f9ad --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__blank_context_line.snap @@ -0,0 +1,14 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"proposed patch to 1 file (+1 -1) " +" └ example.txt " +" 1 " +" 2 -Y " +" 2 +Y changed " +" " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap new file mode 100644 index 0000000000..b92dcbd646 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__single_line_replacement_counts.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"proposed patch to 1 file (+1 -1) " +" └ README.md " +" 1 -# Codex CLI (Rust Implementation) " +" 1 +# Codex CLI (Rust Implementation) banana " +" " +" " +" " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap index 9a73c0c32b..459791e024 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__update_details_with_rename.snap @@ -1,6 +1,5 @@ --- source: tui/src/diff_render.rs -assertion_line: 380 expression: terminal.backend() --- "proposed patch to 1 file (+1 -1) " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap new file mode 100644 index 0000000000..0f4bfd5aed --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__vertical_ellipsis_between_hunks.snap @@ -0,0 +1,20 @@ +--- +source: tui/src/diff_render.rs +expression: terminal.backend() +--- +"proposed patch to 1 file (+2 -2) " +" └ example.txt " +" 1 line 1 " +" 2 -line 2 " +" 2 +line two changed " +" 3 line 3 " +" 4 line 4 " +" 5 line 5 " +" ⋮ " +" 6 line 6 " +" 7 line 7 " +" 8 line 8 " +" 9 -line 9 " +" 9 +line nine changed " +" 10 line 10 " +" " diff --git a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap index 14ff468e38..b14dafaa3c 100644 --- a/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap +++ b/codex-rs/tui/src/snapshots/codex_tui__diff_render__tests__wrap_behavior_insert.snap @@ -1,10 +1,9 @@ --- source: tui/src/diff_render.rs -assertion_line: 380 expression: terminal.backend() --- -" 1 +this is a very long line that should wrap across multiple terminal col " -" umns and continue " +" 1 +this is a very long line that should wrap across multiple terminal co " +" lumns and continue " " " " " " " diff --git a/codex-rs/tui/src/status_indicator_widget.rs b/codex-rs/tui/src/status_indicator_widget.rs index f63fc836a6..df4910fe91 100644 --- a/codex-rs/tui/src/status_indicator_widget.rs +++ b/codex-rs/tui/src/status_indicator_widget.rs @@ -19,6 +19,7 @@ use unicode_width::UnicodeWidthStr; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; use crate::shimmer::shimmer_spans; +use crate::tui::FrameRequester; // We render the live text using markdown so it visually matches the history // cells. Before rendering we strip any ANSI escape sequences to avoid writing @@ -29,6 +30,8 @@ pub(crate) struct StatusIndicatorWidget { /// Latest text to display (truncated to the available width at render /// time). text: String, + /// Animated header text (defaults to "Working"). + header: String, /// Animation state: reveal target `text` progressively like a typewriter. /// We compute the currently visible prefix length based on the current @@ -39,18 +42,21 @@ pub(crate) struct StatusIndicatorWidget { reveal_len_at_base: usize, start_time: Instant, app_event_tx: AppEventSender, + frame_requester: FrameRequester, } impl StatusIndicatorWidget { - pub(crate) fn new(app_event_tx: AppEventSender) -> Self { + pub(crate) fn new(app_event_tx: AppEventSender, frame_requester: FrameRequester) -> Self { Self { text: String::from("waiting for model"), + header: String::from("Working"), last_target_len: 0, base_frame: 0, reveal_len_at_base: 0, start_time: Instant::now(), app_event_tx, + frame_requester, } } @@ -92,6 +98,13 @@ impl StatusIndicatorWidget { self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt)); } + /// Update the animated header label (left of the brackets). + pub(crate) fn update_header(&mut self, header: String) { + if self.header != header { + self.header = header; + } + } + /// Reset the animation and start revealing `text` from the beginning. #[cfg(test)] pub(crate) fn restart_with_text(&mut self, text: String) { @@ -143,13 +156,13 @@ impl WidgetRef for StatusIndicatorWidget { } // Schedule next animation frame. - self.app_event_tx - .send(AppEvent::ScheduleFrameIn(Duration::from_millis(100))); + self.frame_requester + .schedule_frame_in(Duration::from_millis(32)); let idx = self.current_frame(); let elapsed = self.start_time.elapsed().as_secs(); let shown_now = self.current_shown_len(idx); let status_prefix: String = self.text.chars().take(shown_now).collect(); - let animated_spans = shimmer_spans("Working"); + let animated_spans = shimmer_spans(&self.header); // Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback. let inner_width = area.width as usize; @@ -213,13 +226,13 @@ mod tests { use super::*; use crate::app_event::AppEvent; use crate::app_event_sender::AppEventSender; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; #[test] fn renders_without_left_border_or_padding() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hello".to_string()); let area = ratatui::layout::Rect::new(0, 0, 30, 1); @@ -235,9 +248,9 @@ mod tests { #[test] fn working_header_is_present_on_last_line() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hi".to_string()); // Ensure some frames elapse so we get a stable state. std::thread::sleep(std::time::Duration::from_millis(120)); @@ -256,9 +269,9 @@ mod tests { #[test] fn header_starts_at_expected_position() { - let (tx_raw, _rx) = channel::(); + let (tx_raw, _rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let mut w = StatusIndicatorWidget::new(tx); + let mut w = StatusIndicatorWidget::new(tx, crate::tui::FrameRequester::test_dummy()); w.restart_with_text("Hello".to_string()); std::thread::sleep(std::time::Duration::from_millis(120)); diff --git a/codex-rs/tui/src/streaming/controller.rs b/codex-rs/tui/src/streaming/controller.rs index 5eae2a91f6..5fb60e78a5 100644 --- a/codex-rs/tui/src/streaming/controller.rs +++ b/codex-rs/tui/src/streaming/controller.rs @@ -2,7 +2,6 @@ use codex_core::config::Config; use ratatui::text::Line; use super::HeaderEmitter; -use super::StreamKind; use super::StreamState; /// Sink for history insertions and animation control. @@ -18,7 +17,7 @@ pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEve impl HistorySink for AppEventHistorySink { fn insert_history(&self, lines: Vec>) { self.0 - .send(crate::app_event::AppEvent::InsertHistory(lines)) + .send(crate::app_event::AppEvent::InsertHistoryLines(lines)) } fn start_commit_animation(&self) { self.0 @@ -36,8 +35,8 @@ type Lines = Vec>; pub(crate) struct StreamController { config: Config, header: HeaderEmitter, - states: [StreamState; 2], - current_stream: Option, + state: StreamState, + active: bool, finishing_after_drain: bool, } @@ -46,8 +45,8 @@ impl StreamController { Self { config, header: HeaderEmitter::new(), - states: [StreamState::new(), StreamState::new()], - current_stream: None, + state: StreamState::new(), + active: false, finishing_after_drain: false, } } @@ -57,29 +56,18 @@ impl StreamController { } pub(crate) fn is_write_cycle_active(&self) -> bool { - self.current_stream.is_some() + self.active } pub(crate) fn clear_all(&mut self) { - self.states.iter_mut().for_each(|s| s.clear()); - self.current_stream = None; + self.state.clear(); + self.active = false; self.finishing_after_drain = false; // leave header state unchanged; caller decides when to reset } - #[inline] - fn idx(kind: StreamKind) -> usize { - kind as usize - } - fn state(&self, kind: StreamKind) -> &StreamState { - &self.states[Self::idx(kind)] - } - fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState { - &mut self.states[Self::idx(kind)] - } - - fn emit_header_if_needed(&mut self, kind: StreamKind, out_lines: &mut Lines) -> bool { - self.header.maybe_emit(kind, out_lines) + fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool { + self.header.maybe_emit(out_lines) } #[inline] @@ -93,56 +81,23 @@ impl StreamController { } } - /// Begin a stream, flushing previously completed lines from any other - /// active stream to maintain ordering. - pub(crate) fn begin(&mut self, kind: StreamKind, sink: &impl HistorySink) { - if let Some(current) = self.current_stream - && current != kind - { - // Synchronously flush completed lines from previous stream. - let cfg = self.config.clone(); - let prev_state = self.state_mut(current); - let newly_completed = prev_state.collector.commit_complete_lines(&cfg); - if !newly_completed.is_empty() { - prev_state.enqueue(newly_completed); - } - let step = prev_state.drain_all(); - if !step.history.is_empty() { - let mut lines: Lines = Vec::new(); - self.emit_header_if_needed(current, &mut lines); - lines.extend(step.history); - // Ensure at most one trailing blank after the flushed block. - Self::ensure_single_trailing_blank(&mut lines); - sink.insert_history(lines); - } - self.current_stream = None; - } - - if self.current_stream != Some(kind) { - let prev = self.current_stream; - self.current_stream = Some(kind); - // Starting a new stream cancels any pending finish-from-previous-stream animation. - self.finishing_after_drain = false; - if prev.is_some() { - self.header.reset_for_stream(kind); - } - // Emit header immediately for reasoning; for answers, defer to first commit. - if matches!(kind, StreamKind::Reasoning) { - let mut header_lines = Vec::new(); - if self.emit_header_if_needed(kind, &mut header_lines) { - sink.insert_history(header_lines); - } - } + /// Begin an answer stream. Does not emit header yet; it is emitted on first commit. + pub(crate) fn begin(&mut self, _sink: &impl HistorySink) { + // Starting a new stream cancels any pending finish-from-previous-stream animation. + if !self.active { + self.header.reset_for_stream(); } + self.finishing_after_drain = false; + self.active = true; } /// Push a delta; if it contains a newline, commit completed lines and start animation. pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) { - let Some(kind) = self.current_stream else { + if !self.active { return; - }; + } let cfg = self.config.clone(); - let state = self.state_mut(kind); + let state = &mut self.state; // Record that at least one delta was received for this stream if !delta.is_empty() { state.has_seen_delta = true; @@ -157,42 +112,22 @@ impl StreamController { } } - /// Insert a reasoning section break and commit any newly completed lines. - pub(crate) fn insert_reasoning_section_break(&mut self, sink: &impl HistorySink) { - if self.current_stream != Some(StreamKind::Reasoning) { - self.begin(StreamKind::Reasoning, sink); - } - let cfg = self.config.clone(); - let state = self.state_mut(StreamKind::Reasoning); - state.collector.insert_section_break(); - let newly_completed = state.collector.commit_complete_lines(&cfg); - if !newly_completed.is_empty() { - state.enqueue(newly_completed); - sink.start_commit_animation(); - } - } - /// Finalize the active stream. If `flush_immediately` is true, drain and emit now. - pub(crate) fn finalize( - &mut self, - kind: StreamKind, - flush_immediately: bool, - sink: &impl HistorySink, - ) -> bool { - if self.current_stream != Some(kind) { + pub(crate) fn finalize(&mut self, flush_immediately: bool, sink: &impl HistorySink) -> bool { + if !self.active { return false; } let cfg = self.config.clone(); // Finalize collector first. let remaining = { - let state = self.state_mut(kind); + let state = &mut self.state; state.collector.finalize_and_drain(&cfg) }; if flush_immediately { // Collect all output first to avoid emitting headers when there is no content. let mut out_lines: Lines = Vec::new(); { - let state = self.state_mut(kind); + let state = &mut self.state; if !remaining.is_empty() { state.enqueue(remaining); } @@ -201,28 +136,28 @@ impl StreamController { } if !out_lines.is_empty() { let mut lines_with_header: Lines = Vec::new(); - self.emit_header_if_needed(kind, &mut lines_with_header); + self.emit_header_if_needed(&mut lines_with_header); lines_with_header.extend(out_lines); Self::ensure_single_trailing_blank(&mut lines_with_header); sink.insert_history(lines_with_header); } // Cleanup - self.state_mut(kind).clear(); - // Allow a subsequent block of the same kind in this turn to emit its header. - self.header.allow_reemit_for_same_kind_in_turn(kind); + self.state.clear(); + // Allow a subsequent block in this turn to emit its header. + self.header.allow_reemit_in_turn(); // Also clear the per-stream emitted flag so the header can render again. - self.header.reset_for_stream(kind); - self.current_stream = None; + self.header.reset_for_stream(); + self.active = false; self.finishing_after_drain = false; true } else { if !remaining.is_empty() { - let state = self.state_mut(kind); + let state = &mut self.state; state.enqueue(remaining); } // Spacer animated out - self.state_mut(kind).enqueue(vec![Line::from("")]); + self.state.enqueue(vec![Line::from("")]); self.finishing_after_drain = true; sink.start_commit_animation(); false @@ -231,32 +166,29 @@ impl StreamController { /// Step animation: commit at most one queued line and handle end-of-drain cleanup. pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool { - let Some(kind) = self.current_stream else { + if !self.active { return false; - }; - let step = { - let state = self.state_mut(kind); - state.step() - }; + } + let step = { self.state.step() }; if !step.history.is_empty() { let mut lines: Lines = Vec::new(); - self.emit_header_if_needed(kind, &mut lines); + self.emit_header_if_needed(&mut lines); let mut out = lines; out.extend(step.history); sink.insert_history(out); } - let is_idle = self.state(kind).is_idle(); + let is_idle = self.state.is_idle(); if is_idle { sink.stop_commit_animation(); if self.finishing_after_drain { // Reset and notify - self.state_mut(kind).clear(); - // Allow a subsequent block of the same kind in this turn to emit its header. - self.header.allow_reemit_for_same_kind_in_turn(kind); + self.state.clear(); + // Allow a subsequent block in this turn to emit its header. + self.header.allow_reemit_in_turn(); // Also clear the per-stream emitted flag so the header can render again. - self.header.reset_for_stream(kind); - self.current_stream = None; + self.header.reset_for_stream(); + self.active = false; self.finishing_after_drain = false; return true; } @@ -267,24 +199,14 @@ impl StreamController { /// Apply a full final answer: replace queued content with only the remaining tail, /// then finalize immediately and notify completion. pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool { - self.apply_full_final(StreamKind::Answer, message, true, sink) + self.apply_full_final(message, sink) } - pub(crate) fn apply_final_reasoning(&mut self, message: &str, sink: &impl HistorySink) -> bool { - self.apply_full_final(StreamKind::Reasoning, message, false, sink) - } - - fn apply_full_final( - &mut self, - kind: StreamKind, - message: &str, - immediate: bool, - sink: &impl HistorySink, - ) -> bool { - self.begin(kind, sink); + fn apply_full_final(&mut self, message: &str, sink: &impl HistorySink) -> bool { + self.begin(sink); { - let state = self.state_mut(kind); + let state = &mut self.state; // Only inject the final full message if we have not seen any deltas for this stream. // If deltas were received, rely on the collector's existing buffer to avoid duplication. if !state.has_seen_delta && !message.is_empty() { @@ -301,7 +223,6 @@ impl StreamController { .replace_with_and_mark_committed(&msg, committed); } } - - self.finalize(kind, immediate, sink) + self.finalize(true, sink) } } diff --git a/codex-rs/tui/src/streaming/mod.rs b/codex-rs/tui/src/streaming/mod.rs index bb4fdcb6ff..1e670738f1 100644 --- a/codex-rs/tui/src/streaming/mod.rs +++ b/codex-rs/tui/src/streaming/mod.rs @@ -2,12 +2,6 @@ use crate::markdown_stream::AnimatedLineStreamer; use crate::markdown_stream::MarkdownStreamCollector; pub(crate) mod controller; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum StreamKind { - Answer, - Reasoning, -} - pub(crate) struct StreamState { pub(crate) collector: MarkdownStreamCollector, pub(crate) streamer: AnimatedLineStreamer, @@ -42,92 +36,44 @@ impl StreamState { } pub(crate) struct HeaderEmitter { - reasoning_emitted_this_turn: bool, - answer_emitted_this_turn: bool, - reasoning_emitted_in_stream: bool, - answer_emitted_in_stream: bool, + emitted_this_turn: bool, + emitted_in_stream: bool, } impl HeaderEmitter { pub(crate) fn new() -> Self { Self { - reasoning_emitted_this_turn: false, - answer_emitted_this_turn: false, - reasoning_emitted_in_stream: false, - answer_emitted_in_stream: false, + emitted_this_turn: false, + emitted_in_stream: false, } } pub(crate) fn reset_for_new_turn(&mut self) { - self.reasoning_emitted_this_turn = false; - self.answer_emitted_this_turn = false; - self.reasoning_emitted_in_stream = false; - self.answer_emitted_in_stream = false; + self.emitted_this_turn = false; + self.emitted_in_stream = false; } - pub(crate) fn reset_for_stream(&mut self, kind: StreamKind) { - match kind { - StreamKind::Reasoning => self.reasoning_emitted_in_stream = false, - StreamKind::Answer => self.answer_emitted_in_stream = false, - } + pub(crate) fn reset_for_stream(&mut self) { + self.emitted_in_stream = false; } - pub(crate) fn has_emitted_for_stream(&self, kind: StreamKind) -> bool { - match kind { - StreamKind::Reasoning => self.reasoning_emitted_in_stream, - StreamKind::Answer => self.answer_emitted_in_stream, - } + /// Allow emitting the header again within the current turn after a finalize. + pub(crate) fn allow_reemit_in_turn(&mut self) { + self.emitted_this_turn = false; } - /// Allow emitting the header again for the same kind within the current turn. - /// - /// This is used when a stream (e.g., Answer) is finalized and a subsequent - /// block of the same kind is started within the same turn. Without this, - /// only the first block would render a header. - pub(crate) fn allow_reemit_for_same_kind_in_turn(&mut self, kind: StreamKind) { - match kind { - StreamKind::Reasoning => self.reasoning_emitted_this_turn = false, - StreamKind::Answer => self.answer_emitted_this_turn = false, - } - } - - pub(crate) fn maybe_emit( - &mut self, - kind: StreamKind, - out_lines: &mut Vec>, - ) -> bool { - let already_emitted_this_turn = match kind { - StreamKind::Reasoning => self.reasoning_emitted_this_turn, - StreamKind::Answer => self.answer_emitted_this_turn, - }; - let already_emitted_in_stream = self.has_emitted_for_stream(kind); - if !already_emitted_in_stream && !already_emitted_this_turn { - out_lines.push(render_header_line(kind)); - match kind { - StreamKind::Reasoning => { - self.reasoning_emitted_in_stream = true; - self.reasoning_emitted_this_turn = true; - // Reset opposite header so it may be emitted again this turn - self.answer_emitted_this_turn = false; - } - StreamKind::Answer => { - self.answer_emitted_in_stream = true; - self.answer_emitted_this_turn = true; - // Reset opposite header so it may be emitted again this turn - self.reasoning_emitted_this_turn = false; - } - } - true - } else { - false + pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec>) -> bool { + if !self.emitted_in_stream && !self.emitted_this_turn { + out_lines.push(render_header_line()); + self.emitted_in_stream = true; + self.emitted_this_turn = true; + return true; } + false } } -fn render_header_line(kind: StreamKind) -> ratatui::text::Line<'static> { +fn render_header_line() -> ratatui::text::Line<'static> { use ratatui::style::Stylize; - match kind { - StreamKind::Reasoning => ratatui::text::Line::from("thinking".magenta().italic()), - StreamKind::Answer => ratatui::text::Line::from("codex".magenta().bold()), - } + ratatui::text::Line::from("codex".magenta().bold()) } diff --git a/codex-rs/tui/src/transcript_app.rs b/codex-rs/tui/src/transcript_app.rs new file mode 100644 index 0000000000..059e2a07c8 --- /dev/null +++ b/codex-rs/tui/src/transcript_app.rs @@ -0,0 +1,209 @@ +use std::io::Result; + +use crate::insert_history; +use crate::tui; +use crate::tui::TuiEvent; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; +use ratatui::buffer::Buffer; +use ratatui::layout::Rect; +use ratatui::style::Color; +use ratatui::style::Style; +use ratatui::style::Styled; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::text::Span; +use ratatui::widgets::Paragraph; +use ratatui::widgets::WidgetRef; + +pub(crate) struct TranscriptApp { + pub(crate) transcript_lines: Vec>, + pub(crate) scroll_offset: usize, + pub(crate) is_done: bool, +} + +impl TranscriptApp { + pub(crate) fn new(transcript_lines: Vec>) -> Self { + Self { + transcript_lines, + scroll_offset: 0, + is_done: false, + } + } + + pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> { + match event { + TuiEvent::Key(key_event) => self.handle_key_event(tui, key_event), + TuiEvent::Draw => { + tui.draw(u16::MAX, |frame| { + self.render(frame.area(), frame.buffer); + })?; + } + _ => {} + } + Ok(()) + } + + pub(crate) fn insert_lines(&mut self, lines: Vec>) { + self.transcript_lines.extend(lines); + } + + fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) { + match key_event { + KeyEvent { + code: KeyCode::Char('q'), + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('t'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } + | KeyEvent { + code: KeyCode::Char('c'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + } => { + self.is_done = true; + } + KeyEvent { + code: KeyCode::Up, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = self.scroll_offset.saturating_sub(1); + } + KeyEvent { + code: KeyCode::Down, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = self.scroll_offset.saturating_add(1); + } + KeyEvent { + code: KeyCode::PageUp, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let area = self.scroll_area(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize); + } + KeyEvent { + code: KeyCode::PageDown, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + let area = self.scroll_area(tui.terminal.viewport_area); + self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize); + } + KeyEvent { + code: KeyCode::Home, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = 0; + } + KeyEvent { + code: KeyCode::End, + kind: KeyEventKind::Press | KeyEventKind::Repeat, + .. + } => { + self.scroll_offset = usize::MAX; + } + _ => {} + } + } + + fn scroll_area(&self, area: Rect) -> Rect { + let mut area = area; + // Reserve 1 line for the header and 4 lines for the bottom status section. This matches the chat composer. + area.y = area.y.saturating_add(1); + area.height = area.height.saturating_sub(5); + area + } + + fn render(&mut self, area: Rect, buf: &mut Buffer) { + Span::from("/ ".repeat(area.width as usize / 2)) + .dim() + .render_ref(area, buf); + Span::from("/ T R A N S C R I P T") + .dim() + .render_ref(area, buf); + + // Main content area (excludes header and bottom status section) + let content_area = self.scroll_area(area); + let wrapped = insert_history::word_wrap_lines(&self.transcript_lines, content_area.width); + + // Clamp scroll offset to valid range + self.scroll_offset = self + .scroll_offset + .min(wrapped.len().saturating_sub(content_area.height as usize)); + let start = self.scroll_offset; + let end = (start + content_area.height as usize).min(wrapped.len()); + let page = &wrapped[start..end]; + Paragraph::new(page.to_vec()).render_ref(content_area, buf); + + // Fill remaining visible lines (if any) with a leading '~' in the first column. + let visible = (end - start) as u16; + if content_area.height > visible { + let extra = content_area.height - visible; + for i in 0..extra { + let y = content_area.y.saturating_add(visible + i); + Span::from("~") + .dim() + .render_ref(Rect::new(content_area.x, y, 1, 1), buf); + } + } + + // Bottom status section (4 lines): separator with % scrolled, then key hints (styled like chat composer) + let sep_y = content_area.bottom(); + let sep_rect = Rect::new(area.x, sep_y, area.width, 1); + let hints_rect = Rect::new(area.x, sep_y + 1, area.width, 2); + + // Separator line (dim) + Span::from("─".repeat(sep_rect.width as usize)) + .dim() + .render_ref(sep_rect, buf); + + // Scroll percentage (0-100%) aligned near the right edge + let max_scroll = wrapped.len().saturating_sub(content_area.height as usize); + let percent: u8 = if max_scroll == 0 { + 100 + } else { + (((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round() + as u8 + }; + let pct_text = format!(" {percent}% "); + let pct_w = pct_text.chars().count() as u16; + let pct_x = sep_rect.x + sep_rect.width - pct_w - 1; + Span::from(pct_text) + .dim() + .render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf); + + let key_hint_style = Style::default().fg(Color::Cyan); + + let hints1 = vec![ + " ".into(), + "↑".set_style(key_hint_style), + "/".into(), + "↓".set_style(key_hint_style), + " scroll ".into(), + "PgUp".set_style(key_hint_style), + "/".into(), + "PgDn".set_style(key_hint_style), + " page ".into(), + "Home".set_style(key_hint_style), + "/".into(), + "End".set_style(key_hint_style), + " jump".into(), + ]; + + let hints2 = vec![" ".into(), "q".set_style(key_hint_style), " quit".into()]; + Paragraph::new(vec![Line::from(hints1).dim(), Line::from(hints2).dim()]) + .render_ref(hints_rect, buf); + } +} diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 0447e32ae9..4ec17ea8af 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -1,28 +1,39 @@ use std::io::Result; use std::io::Stdout; use std::io::stdout; +use std::pin::Pin; +use std::time::Duration; +use std::time::Instant; -use codex_core::config::Config; +use crossterm::SynchronizedUpdate; +use crossterm::cursor; use crossterm::cursor::MoveTo; use crossterm::event::DisableBracketedPaste; use crossterm::event::EnableBracketedPaste; +use crossterm::event::KeyCode; +use crossterm::event::KeyEvent; +use crossterm::event::KeyEventKind; use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::PopKeyboardEnhancementFlags; use crossterm::event::PushKeyboardEnhancementFlags; -use crossterm::terminal::Clear; -use crossterm::terminal::ClearType; +use crossterm::terminal::ScrollUp; +use ratatui::backend::Backend; use ratatui::backend::CrosstermBackend; use ratatui::crossterm::execute; use ratatui::crossterm::terminal::disable_raw_mode; use ratatui::crossterm::terminal::enable_raw_mode; +use ratatui::layout::Offset; +use ratatui::text::Line; -use crate::custom_terminal::Terminal; +use crate::custom_terminal; +use crate::custom_terminal::Terminal as CustomTerminal; +use tokio::select; +use tokio_stream::Stream; /// A type alias for the terminal type used in this application -pub type Tui = Terminal>; +pub type Terminal = CustomTerminal>; -/// Initialize the terminal (inline viewport; history stays in normal scrollback) -pub fn init(_config: &Config) -> Result { +pub fn set_modes() -> Result<()> { execute!(stdout(), EnableBracketedPaste)?; enable_raw_mode()?; @@ -40,13 +51,37 @@ pub fn init(_config: &Config) -> Result { | KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS ) ); + Ok(()) +} + +/// Restore the terminal to its original state. +/// Inverse of `set_modes`. +pub fn restore() -> Result<()> { + // Pop may fail on platforms that didn't support the push; ignore errors. + let _ = execute!(stdout(), PopKeyboardEnhancementFlags); + execute!(stdout(), DisableBracketedPaste)?; + disable_raw_mode()?; + let _ = execute!(stdout(), crossterm::cursor::Show); + Ok(()) +} + +/// Initialize the terminal (inline viewport; history stays in normal scrollback) +pub fn init() -> Result { + set_modes()?; + set_panic_hook(); - // Clear screen and move cursor to top-left before drawing UI - execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?; + // Instead of clearing the screen (which can drop scrollback in some terminals), + // scroll existing lines up until the cursor reaches the top, then start at (0, 0). + if let Ok((_x, y)) = cursor::position() + && y > 0 + { + execute!(stdout(), ScrollUp(y))?; + } + execute!(stdout(), MoveTo(0, 0))?; let backend = CrosstermBackend::new(stdout()); - let tui = Terminal::with_options(backend)?; + let tui = CustomTerminal::with_options(backend)?; Ok(tui) } @@ -58,11 +93,223 @@ fn set_panic_hook() { })); } -/// Restore the terminal to its original state -pub fn restore() -> Result<()> { - // Pop may fail on platforms that didn't support the push; ignore errors. - let _ = execute!(stdout(), PopKeyboardEnhancementFlags); - execute!(stdout(), DisableBracketedPaste)?; - disable_raw_mode()?; - Ok(()) +#[derive(Debug)] +pub enum TuiEvent { + Key(KeyEvent), + Paste(String), + Draw, + #[cfg(unix)] + ResumeFromSuspend, +} + +pub struct Tui { + frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, + draw_tx: tokio::sync::broadcast::Sender<()>, + pub(crate) terminal: Terminal, + pending_history_lines: Vec>, +} + +#[derive(Clone, Debug)] +pub struct FrameRequester { + frame_schedule_tx: tokio::sync::mpsc::UnboundedSender, +} +impl FrameRequester { + pub fn schedule_frame(&self) { + let _ = self.frame_schedule_tx.send(Instant::now()); + } + pub fn schedule_frame_in(&self, dur: Duration) { + let _ = self.frame_schedule_tx.send(Instant::now() + dur); + } +} + +#[cfg(test)] +impl FrameRequester { + /// Create a no-op frame requester for tests. + pub(crate) fn test_dummy() -> Self { + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + FrameRequester { + frame_schedule_tx: tx, + } + } +} + +impl Tui { + pub fn new(terminal: Terminal) -> Self { + let (frame_schedule_tx, frame_schedule_rx) = tokio::sync::mpsc::unbounded_channel(); + let (draw_tx, _) = tokio::sync::broadcast::channel(1); + + // Spawn background scheduler to coalesce frame requests and emit draws at deadlines. + let draw_tx_clone = draw_tx.clone(); + tokio::spawn(async move { + use tokio::select; + use tokio::time::Instant as TokioInstant; + use tokio::time::sleep_until; + + let mut rx = frame_schedule_rx; + let mut next_deadline: Option = None; + + loop { + let target = next_deadline + .unwrap_or_else(|| Instant::now() + Duration::from_secs(60 * 60 * 24 * 365)); + let sleep_fut = sleep_until(TokioInstant::from_std(target)); + tokio::pin!(sleep_fut); + + select! { + recv = rx.recv() => { + match recv { + Some(at) => { + if next_deadline.is_none_or(|cur| at < cur) { + next_deadline = Some(at); + } + if at <= Instant::now() { + next_deadline = None; + let _ = draw_tx_clone.send(()); + } + } + None => break, + } + } + _ = &mut sleep_fut => { + if next_deadline.is_some() { + next_deadline = None; + let _ = draw_tx_clone.send(()); + } + } + } + } + }); + + Self { + frame_schedule_tx, + draw_tx, + terminal, + pending_history_lines: vec![], + } + } + + pub fn frame_requester(&self) -> FrameRequester { + FrameRequester { + frame_schedule_tx: self.frame_schedule_tx.clone(), + } + } + + pub fn event_stream(&self) -> Pin + Send + 'static>> { + use tokio_stream::StreamExt; + let mut crossterm_events = crossterm::event::EventStream::new(); + let mut draw_rx = self.draw_tx.subscribe(); + let event_stream = async_stream::stream! { + loop { + select! { + Some(Ok(event)) = crossterm_events.next() => { + match event { + crossterm::event::Event::Key(KeyEvent { + code: KeyCode::Char('z'), + modifiers: crossterm::event::KeyModifiers::CONTROL, + kind: KeyEventKind::Press, + .. + }) => { + #[cfg(unix)] + { + let _ = Tui::suspend(); + yield TuiEvent::ResumeFromSuspend; + yield TuiEvent::Draw; + } + } + crossterm::event::Event::Key(key_event) => { + yield TuiEvent::Key(key_event); + } + crossterm::event::Event::Resize(_, _) => { + yield TuiEvent::Draw; + } + crossterm::event::Event::Paste(pasted) => { + yield TuiEvent::Paste(pasted); + } + _ => {} + } + } + result = draw_rx.recv() => { + match result { + Ok(_) => { + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { + // We dropped one or more draw notifications; coalesce to a single draw. + yield TuiEvent::Draw; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + // Sender dropped; stop emitting draws from this source. + } + } + } + } + } + }; + Box::pin(event_stream) + } + + #[cfg(unix)] + fn suspend() -> Result<()> { + restore()?; + unsafe { libc::kill(0, libc::SIGTSTP) }; + set_modes()?; + Ok(()) + } + + pub fn insert_history_lines(&mut self, lines: Vec>) { + self.pending_history_lines.extend(lines); + self.frame_requester().schedule_frame(); + } + + pub fn draw( + &mut self, + height: u16, + draw_fn: impl FnOnce(&mut custom_terminal::Frame), + ) -> Result<()> { + std::io::stdout().sync_update(|_| { + let terminal = &mut self.terminal; + let screen_size = terminal.size()?; + let last_known_screen_size = terminal.last_known_screen_size; + if screen_size != last_known_screen_size { + let cursor_pos = terminal.get_cursor_position()?; + let last_known_cursor_pos = terminal.last_known_cursor_pos; + if cursor_pos.y != last_known_cursor_pos.y { + let cursor_delta = cursor_pos.y as i32 - last_known_cursor_pos.y as i32; + + let new_viewport_area = terminal.viewport_area.offset(Offset { + x: 0, + y: cursor_delta, + }); + terminal.set_viewport_area(new_viewport_area); + terminal.clear()?; + } + } + + let size = terminal.size()?; + + let mut area = terminal.viewport_area; + area.height = height.min(size.height); + area.width = size.width; + if area.bottom() > size.height { + terminal + .backend_mut() + .scroll_region_up(0..area.top(), area.bottom() - size.height)?; + area.y = size.height - area.height; + } + if area != terminal.viewport_area { + terminal.clear()?; + terminal.set_viewport_area(area); + } + if !self.pending_history_lines.is_empty() { + crate::insert_history::insert_history_lines( + terminal, + self.pending_history_lines.clone(), + ); + self.pending_history_lines.clear(); + } + terminal.draw(|frame| { + draw_fn(frame); + })?; + Ok(()) + })? + } } diff --git a/codex-rs/tui/src/user_approval_widget.rs b/codex-rs/tui/src/user_approval_widget.rs index c2d7f70af0..deb2c8ff16 100644 --- a/codex-rs/tui/src/user_approval_widget.rs +++ b/codex-rs/tui/src/user_approval_widget.rs @@ -95,11 +95,11 @@ static PATCH_SELECT_OPTIONS: LazyLock> = LazyLock::new(|| { }); /// A modal prompting the user to approve or deny the pending request. -pub(crate) struct UserApprovalWidget<'a> { +pub(crate) struct UserApprovalWidget { approval_request: ApprovalRequest, app_event_tx: AppEventSender, - confirmation_prompt: Paragraph<'a>, - select_options: &'a Vec, + confirmation_prompt: Paragraph<'static>, + select_options: &'static Vec, /// Currently selected index in *select* mode. selected_option: usize, @@ -137,7 +137,7 @@ fn to_command_display<'a>( lines } -impl UserApprovalWidget<'_> { +impl UserApprovalWidget { pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self { let confirmation_prompt = match &approval_request { ApprovalRequest::Exec { @@ -328,7 +328,7 @@ impl UserApprovalWidget<'_> { } } lines.push(Line::from("")); - self.app_event_tx.send(AppEvent::InsertHistory(lines)); + self.app_event_tx.send(AppEvent::InsertHistoryLines(lines)); let op = match &self.approval_request { ApprovalRequest::Exec { id, .. } => Op::ExecApproval { @@ -356,7 +356,7 @@ impl UserApprovalWidget<'_> { } } -impl WidgetRef for &UserApprovalWidget<'_> { +impl WidgetRef for &UserApprovalWidget { fn render_ref(&self, area: Rect, buf: &mut Buffer) { let prompt_height = self.get_confirmation_prompt_height(area.width); let [prompt_chunk, response_chunk] = Layout::default() @@ -424,11 +424,11 @@ mod tests { use crossterm::event::KeyCode; use crossterm::event::KeyEvent; use crossterm::event::KeyModifiers; - use std::sync::mpsc::channel; + use tokio::sync::mpsc::unbounded_channel; #[test] fn lowercase_shortcut_is_accepted() { - let (tx_raw, rx) = channel::(); + let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let req = ApprovalRequest::Exec { id: "1".to_string(), @@ -438,7 +438,10 @@ mod tests { let mut widget = UserApprovalWidget::new(req, tx); widget.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE)); assert!(widget.is_complete()); - let events: Vec = rx.try_iter().collect(); + let mut events: Vec = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } assert!(events.iter().any(|e| matches!( e, AppEvent::CodexOp(Op::ExecApproval { @@ -450,7 +453,7 @@ mod tests { #[test] fn uppercase_shortcut_is_accepted() { - let (tx_raw, rx) = channel::(); + let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); let req = ApprovalRequest::Exec { id: "2".to_string(), @@ -460,7 +463,10 @@ mod tests { let mut widget = UserApprovalWidget::new(req, tx); widget.handle_key_event(KeyEvent::new(KeyCode::Char('Y'), KeyModifiers::NONE)); assert!(widget.is_complete()); - let events: Vec = rx.try_iter().collect(); + let mut events: Vec = Vec::new(); + while let Ok(ev) = rx.try_recv() { + events.push(ev); + } assert!(events.iter().any(|e| matches!( e, AppEvent::CodexOp(Op::ExecApproval { diff --git a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt b/codex-rs/tui/tests/fixtures/ideal-binary-response.txt index bc1bb5e56d..80aae5d0e4 100644 --- a/codex-rs/tui/tests/fixtures/ideal-binary-response.txt +++ b/codex-rs/tui/tests/fixtures/ideal-binary-response.txt @@ -1,12 +1,9 @@ -thinking -Investigating binary sizes +To get started, describe a task or try one of these commands: -I need to check the codex-rs repository to explain why the project's binaries -are large. The user is likely seeking specifics about the setup: are Rust builds -static, what features are enabled, and is debug information included? It could -be due to static linking, included OpenSSL, or how panic handling is set up. I -should look into the Cargo.toml file to confirm features and profiles without -needing to edit any code. Let's get started on this! + /init - create an AGENTS.md file with instructions for Codex + /status - show current session configuration and token usage + /approvals - choose what Codex can do without approval + /model - choose what model and reasoning effort to use codex I’m going to scan the workspace and Cargo manifests to see build profiles and @@ -37,16 +34,6 @@ dependencies that impact binary size. Then I’ll summarize the main causes. # ratatui = { path = "../../ratatui" } ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" } -Optimizing Rust Release Profile - -I'm reviewing the workspace's release profile, which has settings like lto=fat, -strip=symbols, and codegen-units=1 to reduce binary size. However, it seems that -"strip = symbols" only removes symbols, not debug information. In Rust 1.73+, -both "symbols" and "debuginfo" stripping options are available. I should check -whether using these results in a smaller binary. I’ll also skim through crates -for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox, -tui, login, ollama, and mcp. - ⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done @@ -181,85 +168,6 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done [dev-dependencies] tempfile = "3" -thinking -Considering binary size impacts - -I’m evaluating how binary size is influenced by various factors. There are many -binaries, such as codex and codex-exec, which include dependencies by default, -pulling in features like Tokio and hyper. For instance, reqwest includes -default features like TLS through rustls. If default features remain enabled, -it inflates the size with many additional components. I notice that for musl -targets, using vendored builds of OpenSSL could significantly increase binary -size. This is quite the intricate web of dependencies! - -Analyzing musl and binary size - -I’m considering the implications of building for musl with static linking. -In this case, OpenSSL might come into play through some crates, but reqwest -defaults to using rustls, although openssl-sys vendored could increase binary -size. It’s interesting to think about whether they’re targeting musl, especially -since it seems they might often be building for Mac and Linux with dynamic -linking instead. - -I also realize there’s a difference between debug and release builds — release -should be smaller due to optimizations, but if they’re defaulting to debug -builds, that could lead to much larger binaries. - -Assessing crate impacts on size - -I need to be precise when analyzing binary sizes from various crates. Some -crates, like ratatui-image, include heavy features such as libjpeg decoders, -which can really bulk things up. Then there are the tree-sitter crates that -contain large static grammar tables, adding significant size. The starlark crate -and tracing-subscriber also contribute to size increases. - -With static linking per binary being standard in Rust, it leads to multiple -copies of the same dependencies unless cdylibs are used. That makes the compile -times longer, but using LTO helps reduce bloat somewhat through deduplication. - -Evaluating binary size factors - -Having multiple binaries indeed increases the overall size significantly. -I'm considering options like upgrading to "opt-level = 'z'" or using -"strip=debuginfo" for reducing size. It's interesting that while the profile -only sets symbols for stripping, not debuginfo, this could inflate sizes if -retained in release builds. - -The distinction between "symbols" and "debuginfo" is critical; "symbols" keeps -debug information which may not align with the goal of minimizing size. I’d like -to verify with the Rust documentation, but my network access is restricted. - -Explaining large binary sizes - -I’m analyzing why binaries in this project are so large. The primary reasons -include monolithic static linking, where each binary includes the full -dependency graph without shared libraries. Heavy dependencies such as reqwest, -hyper, and TLS components also contribute significantly to size, along with -image decoders and other libraries. - -Using MUSL targets means vendored OpenSSL can drastically increase the size due -to static linking. Furthermore, if the release profile keeps debug information -due to using "strip = symbols," that can lead to larger binaries as well. It’s a -mix of several factors, including panic strategies and platform-specific linking -behavior. - -Identifying binary size contributors - -I’m considering why binaries are still large even without static linking. The -tracing-subscriber crate potentially adds weight because it includes regex, -though it can sometimes use a lighter version. References from files like "core/ -Cargo.toml" show dependencies like reqwest and tokio, while the tui uses the -image crate. - -To present this clearly, I’ll format the final answer with two main sections: -"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for -reducing size, but I want to stay focused on answering the user's question. - - - - - - codex Here’s what’s driving size in this workspace’s binaries. @@ -295,4 +203,4 @@ assertions—outputs are much larger than cargo build --release. If you want, I can outline targeted trims (e.g., strip = "debuginfo", opt-level = "z", panic abort, tighter tokio/reqwest features) and estimate impact per -binary. \ No newline at end of file +binary.