Merge 178cf1e884 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2025-08-11 14:51:07 -07:00
committed by GitHub
27 changed files with 3332 additions and 330 deletions

View File

@@ -2,6 +2,8 @@
In the codex-rs folder where the rust code lives:
- Crate names are prefixed with `codex-`. For examole, the `core` folder's crate is named `codex-core`
- When using format! and you can inline variables into {}, always do that.
- Never add or modify any code related to `CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR` or `CODEX_SANDBOX_ENV_VAR`.
- 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.

4
codex-rs/Cargo.lock generated
View File

@@ -4781,9 +4781,9 @@ dependencies = [
[[package]]
name = "tokio-util"
version = "0.7.15"
version = "0.7.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66a539a9ad6d5d281510d5bd368c973d636c02dbf8a67300bfb6b950696ad7df"
checksum = "14307c986784f72ef81c89db7d9e28d6ac26d16213b109ea501696195e6e3ce5"
dependencies = [
"bytes",
"futures-core",

View File

@@ -82,8 +82,9 @@ 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 cmd == "apply_patch" => match parse_patch(body) {
[cmd, body] if APPLY_PATCH_COMMANDS.contains(&cmd.as_str()) => match parse_patch(body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
@@ -722,6 +723,31 @@ mod tests {
}
}
#[test]
fn test_literal_applypatch() {
let args = strs_to_strings(&[
"applypatch",
r#"*** Begin Patch
*** Add File: foo
+hi
*** End 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_heredoc() {
let args = strs_to_strings(&[

View File

@@ -46,7 +46,7 @@ tokio = { version = "1", features = [
"rt-multi-thread",
"signal",
] }
tokio-util = "0.7.14"
tokio-util = "0.7.16"
toml = "0.9.4"
toml_edit = "0.23.3"
tracing = { version = "0.1.41", features = ["log"] }

View File

@@ -403,6 +403,8 @@ async fn process_sse<S>(
}
};
trace!("SSE event: {}", sse.data);
let event: SseEvent = match serde_json::from_str(&sse.data) {
Ok(event) => event,
Err(e) => {
@@ -411,7 +413,6 @@ async fn process_sse<S>(
}
};
trace!(?event, "SSE event");
match event.kind.as_str() {
// Individual output item finalised. Forward immediately so the
// rest of the agent can stream assistant text/functions *live*

View File

@@ -51,6 +51,7 @@ use crate::exec::ExecParams;
use crate::exec::ExecToolCallOutput;
use crate::exec::SandboxType;
use crate::exec::StdoutStream;
use crate::exec::StreamOutput;
use crate::exec::process_exec_tool_call;
use crate::exec_env::create_env;
use crate::mcp_connection_manager::McpConnectionManager;
@@ -65,6 +66,7 @@ use crate::models::ResponseItem;
use crate::models::ShellToolCallParams;
use crate::openai_tools::ToolsConfig;
use crate::openai_tools::get_openai_tools;
use crate::parse_command::parse_command;
use crate::plan_tool::handle_update_plan;
use crate::project_doc::get_user_instructions;
use crate::protocol::AgentMessageDeltaEvent;
@@ -402,6 +404,7 @@ impl Session {
call_id,
command: command_for_display.clone(),
cwd,
parsed_cmd: parse_command(&command_for_display),
}),
};
let event = Event {
@@ -429,8 +432,8 @@ impl Session {
// Because stdout and stderr could each be up to 100 KiB, we send
// truncated versions.
const MAX_STREAM_OUTPUT: usize = 5 * 1024; // 5KiB
let stdout = stdout.chars().take(MAX_STREAM_OUTPUT).collect();
let stderr = stderr.chars().take(MAX_STREAM_OUTPUT).collect();
let stdout = stdout.text.chars().take(MAX_STREAM_OUTPUT).collect();
let stderr = stderr.text.chars().take(MAX_STREAM_OUTPUT).collect();
let msg = if is_apply_patch {
EventMsg::PatchApplyEnd(PatchApplyEndEvent {
@@ -502,8 +505,8 @@ impl Session {
Err(e) => {
output_stderr = ExecToolCallOutput {
exit_code: -1,
stdout: String::new(),
stderr: get_error_message_ui(e),
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(get_error_message_ui(e)),
duration: Duration::default(),
};
&output_stderr
@@ -1975,19 +1978,10 @@ async fn handle_container_exec_with_params(
match output_result {
Ok(output) => {
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
duration,
} = &output;
let ExecToolCallOutput { exit_code, .. } = &output;
let is_success = *exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
*exit_code,
*duration,
);
let content = format_exec_output(output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
@@ -2116,19 +2110,10 @@ async fn handle_sandbox_error(
match retry_output_result {
Ok(retry_output) => {
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
duration,
} = &retry_output;
let ExecToolCallOutput { exit_code, .. } = &retry_output;
let is_success = *exit_code == 0;
let content = format_exec_output(
if is_success { stdout } else { stderr },
*exit_code,
*duration,
);
let content = format_exec_output(retry_output);
ResponseInputItem::FunctionCallOutput {
call_id: call_id.clone(),
@@ -2161,7 +2146,14 @@ async fn handle_sandbox_error(
}
/// Exec output is a pre-serialized JSON payload
fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> String {
fn format_exec_output(exec_output: ExecToolCallOutput) -> String {
let ExecToolCallOutput {
exit_code,
stdout,
stderr,
duration,
} = exec_output;
#[derive(Serialize)]
struct ExecMetadata {
exit_code: i32,
@@ -2177,8 +2169,18 @@ fn format_exec_output(output: &str, exit_code: i32, duration: Duration) -> Strin
// round to 1 decimal place
let duration_seconds = ((duration.as_secs_f32()) * 10.0).round() / 10.0;
let is_success = exit_code == 0;
let output = if is_success { stdout } else { stderr };
let mut formatted_output = output.text;
if let Some(truncated_after_lines) = output.truncated_after_lines {
formatted_output.push_str(&format!(
"\n\n[Output truncated after {truncated_after_lines} lines: too many lines or bytes.]",
));
}
let payload = ExecOutput {
output,
output: &formatted_output,
metadata: ExecMetadata {
exit_code,
duration_seconds,

View File

@@ -130,8 +130,8 @@ pub async fn process_exec_tool_call(
let duration = start.elapsed();
match raw_output_result {
Ok(raw_output) => {
let stdout = String::from_utf8_lossy(&raw_output.stdout).to_string();
let stderr = String::from_utf8_lossy(&raw_output.stderr).to_string();
let stdout = raw_output.stdout.from_utf8_lossy();
let stderr = raw_output.stderr.from_utf8_lossy();
#[cfg(target_family = "unix")]
match raw_output.exit_status.signal() {
@@ -146,7 +146,9 @@ pub async fn process_exec_tool_call(
if exit_code != 0 && is_likely_sandbox_denied(sandbox_type, exit_code) {
return Err(CodexErr::Sandbox(SandboxErr::Denied(
exit_code, stdout, stderr,
exit_code,
stdout.text,
stderr.text,
)));
}
@@ -243,18 +245,41 @@ fn is_likely_sandbox_denied(sandbox_type: SandboxType, exit_code: i32) -> bool {
true
}
#[derive(Debug)]
pub struct StreamOutput<T> {
pub text: T,
pub truncated_after_lines: Option<u32>,
}
#[derive(Debug)]
pub struct RawExecToolCallOutput {
pub exit_status: ExitStatus,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub stdout: StreamOutput<Vec<u8>>,
pub stderr: StreamOutput<Vec<u8>>,
}
impl StreamOutput<String> {
pub fn new(text: String) -> Self {
Self {
text,
truncated_after_lines: None,
}
}
}
impl StreamOutput<Vec<u8>> {
pub fn from_utf8_lossy(&self) -> StreamOutput<String> {
StreamOutput {
text: String::from_utf8_lossy(&self.text).to_string(),
truncated_after_lines: self.truncated_after_lines,
}
}
}
#[derive(Debug)]
pub struct ExecToolCallOutput {
pub exit_code: i32,
pub stdout: String,
pub stderr: String,
pub stdout: StreamOutput<String>,
pub stderr: StreamOutput<String>,
pub duration: Duration,
}
@@ -363,7 +388,7 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
max_lines: usize,
stream: Option<StdoutStream>,
is_stderr: bool,
) -> io::Result<Vec<u8>> {
) -> io::Result<StreamOutput<Vec<u8>>> {
let mut buf = Vec::with_capacity(max_output.min(8 * 1024));
let mut tmp = [0u8; 8192];
@@ -413,7 +438,16 @@ async fn read_capped<R: AsyncRead + Unpin + Send + 'static>(
// Continue reading to EOF to avoid back-pressure, but discard once caps are hit.
}
Ok(buf)
let truncated = remaining_lines == 0 || remaining_bytes == 0;
Ok(StreamOutput {
text: buf,
truncated_after_lines: if truncated {
Some((max_lines - remaining_lines) as u32)
} else {
None
},
})
}
#[cfg(unix)]

View File

@@ -28,6 +28,7 @@ mod mcp_connection_manager;
mod mcp_tool_call;
mod message_history;
mod model_provider_info;
pub mod parse_command;
pub use model_provider_info::BUILT_IN_OSS_MODEL_PROVIDER_ID;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;

View File

@@ -1,5 +1,6 @@
use serde::Deserialize;
use serde::Serialize;
use serde_json::Value as JsonValue;
use serde_json::json;
use std::collections::BTreeMap;
use std::collections::HashMap;
@@ -81,6 +82,8 @@ pub(crate) enum JsonSchema {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
},
/// MCP schema allows "number" | "integer" for Number
#[serde(alias = "integer")]
Number {
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
@@ -296,7 +299,13 @@ pub(crate) fn mcp_tool_to_openai_tool(
input_schema.properties = Some(serde_json::Value::Object(serde_json::Map::new()));
}
let serialized_input_schema = serde_json::to_value(input_schema)?;
// Serialize to a raw JSON value so we can sanitize schemas coming from MCP
// servers. Some servers omit the top-level or nested `type` in JSON
// Schemas (e.g. using enum/anyOf), or use unsupported variants like
// `integer`. Our internal JsonSchema is a small subset and requires
// `type`, so we coerce/sanitize here for compatibility.
let mut serialized_input_schema = serde_json::to_value(input_schema)?;
sanitize_json_schema(&mut serialized_input_schema);
let input_schema = serde_json::from_value::<JsonSchema>(serialized_input_schema)?;
Ok(ResponsesApiTool {
@@ -307,6 +316,120 @@ pub(crate) fn mcp_tool_to_openai_tool(
})
}
/// Sanitize a JSON Schema (as serde_json::Value) so it can fit our limited
/// JsonSchema enum. This function:
/// - Ensures every schema object has a "type". If missing, infers it from
/// common keywords (properties => object, items => array, enum/const/format => string)
/// and otherwise defaults to "string".
/// - Fills required child fields (e.g. array items, object properties) with
/// permissive defaults when absent.
fn sanitize_json_schema(value: &mut JsonValue) {
match value {
JsonValue::Bool(_) => {
// JSON Schema boolean form: true/false. Coerce to an accept-all string.
*value = json!({ "type": "string" });
}
JsonValue::Array(arr) => {
for v in arr.iter_mut() {
sanitize_json_schema(v);
}
}
JsonValue::Object(map) => {
// First, recursively sanitize known nested schema holders
if let Some(props) = map.get_mut("properties") {
if let Some(props_map) = props.as_object_mut() {
for (_k, v) in props_map.iter_mut() {
sanitize_json_schema(v);
}
}
}
if let Some(items) = map.get_mut("items") {
sanitize_json_schema(items);
}
// Some schemas use oneOf/anyOf/allOf - sanitize their entries
for combiner in ["oneOf", "anyOf", "allOf", "prefixItems"] {
if let Some(v) = map.get_mut(combiner) {
sanitize_json_schema(v);
}
}
// Normalize/ensure type
let mut ty = map
.get("type")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
// If type is an array (union), pick first supported; else leave to inference
if ty.is_none() {
if let Some(JsonValue::Array(types)) = map.get("type") {
for t in types {
if let Some(tt) = t.as_str() {
if matches!(
tt,
"object" | "array" | "string" | "number" | "integer" | "boolean"
) {
ty = Some(tt.to_string());
break;
}
}
}
}
}
// Infer type if still missing
if ty.is_none() {
if map.contains_key("properties")
|| map.contains_key("required")
|| map.contains_key("additionalProperties")
{
ty = Some("object".to_string());
} else if map.contains_key("items") || map.contains_key("prefixItems") {
ty = Some("array".to_string());
} else if map.contains_key("enum")
|| map.contains_key("const")
|| map.contains_key("format")
{
ty = Some("string".to_string());
} else if map.contains_key("minimum")
|| map.contains_key("maximum")
|| map.contains_key("exclusiveMinimum")
|| map.contains_key("exclusiveMaximum")
|| map.contains_key("multipleOf")
{
ty = Some("number".to_string());
}
}
// If we still couldn't infer, default to string
let ty = ty.unwrap_or_else(|| "string".to_string());
map.insert("type".to_string(), JsonValue::String(ty.to_string()));
// Ensure object schemas have properties map
if ty == "object" {
if !map.contains_key("properties") {
map.insert(
"properties".to_string(),
JsonValue::Object(serde_json::Map::new()),
);
}
// If additionalProperties is an object schema, sanitize it too.
// Leave booleans as-is, since JSON Schema allows boolean here.
if let Some(ap) = map.get_mut("additionalProperties") {
let is_bool = matches!(ap, JsonValue::Bool(_));
if !is_bool {
sanitize_json_schema(ap);
}
}
}
// Ensure array schemas have items
if ty == "array" && !map.contains_key("items") {
map.insert("items".to_string(), json!({ "type": "string" }));
}
}
_ => {}
}
}
/// Returns a list of OpenAiTools based on the provided config and MCP tools.
/// Note that the keys of mcp_tools should be fully qualified names. See
/// [`McpConnectionManager`] for more details.
@@ -351,6 +474,7 @@ pub(crate) fn get_openai_tools(
mod tests {
use crate::model_family::find_family_for_model;
use mcp_types::ToolInputSchema;
use pretty_assertions::assert_eq;
use super::*;
@@ -497,4 +621,212 @@ mod tests {
})
);
}
#[test]
fn test_mcp_tool_property_missing_type_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/search".to_string(),
mcp_types::Tool {
name: "search".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"query": {
"description": "search query"
}
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Search docs".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/search"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/search".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"query".to_string(),
JsonSchema::String {
description: Some("search query".to_string())
}
)]),
required: None,
additional_properties: None,
},
description: "Search docs".to_string(),
strict: false,
})
);
}
#[test]
fn test_mcp_tool_integer_normalized_to_number() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/paginate".to_string(),
mcp_types::Tool {
name: "paginate".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"page": { "type": "integer" }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Pagination".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/paginate"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/paginate".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"page".to_string(),
JsonSchema::Number { description: None }
)]),
required: None,
additional_properties: None,
},
description: "Pagination".to_string(),
strict: false,
})
);
}
#[test]
fn test_mcp_tool_array_without_items_gets_default_string_items() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/tags".to_string(),
mcp_types::Tool {
name: "tags".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"tags": { "type": "array" }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("Tags".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/tags"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/tags".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"tags".to_string(),
JsonSchema::Array {
items: Box::new(JsonSchema::String { description: None }),
description: None
}
)]),
required: None,
additional_properties: None,
},
description: "Tags".to_string(),
strict: false,
})
);
}
#[test]
fn test_mcp_tool_anyof_defaults_to_string() {
let model_family = find_family_for_model("o3").expect("o3 should be a valid model family");
let config = ToolsConfig::new(
&model_family,
AskForApproval::Never,
SandboxPolicy::ReadOnly,
false,
);
let tools = get_openai_tools(
&config,
Some(HashMap::from([(
"dash/value".to_string(),
mcp_types::Tool {
name: "value".to_string(),
input_schema: ToolInputSchema {
properties: Some(serde_json::json!({
"value": { "anyOf": [ { "type": "string" }, { "type": "number" } ] }
})),
required: None,
r#type: "object".to_string(),
},
output_schema: None,
title: None,
annotations: None,
description: Some("AnyOf Value".to_string()),
},
)])),
);
assert_eq_tool_names(&tools, &["shell", "dash/value"]);
assert_eq!(
tools[1],
OpenAiTool::Function(ResponsesApiTool {
name: "dash/value".to_string(),
parameters: JsonSchema::Object {
properties: BTreeMap::from([(
"value".to_string(),
JsonSchema::String { description: None }
)]),
required: None,
additional_properties: None,
},
description: "AnyOf Value".to_string(),
strict: false,
})
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -21,6 +21,7 @@ use crate::config_types::ReasoningEffort as ReasoningEffortConfig;
use crate::config_types::ReasoningSummary as ReasoningSummaryConfig;
use crate::message_history::HistoryEntry;
use crate::model_provider_info::ModelProviderInfo;
use crate::parse_command::ParsedCommand;
use crate::plan_tool::UpdatePlanArgs;
/// Submission Queue Entry - requests from user
@@ -579,6 +580,7 @@ pub struct ExecCommandBeginEvent {
pub command: Vec<String>,
/// The command's working directory if not the default cwd for the agent.
pub cwd: PathBuf,
pub parsed_cmd: Vec<ParsedCommand>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]

View File

@@ -230,7 +230,7 @@ mod tests {
assert_eq!(output.exit_code, 0, "input: {input:?} output: {output:?}");
if let Some(expected) = expected_output {
assert_eq!(
output.stdout, expected,
output.stdout.text, expected,
"input: {input:?} output: {output:?}"
);
}

View File

@@ -1,10 +1,11 @@
#![cfg(target_os = "macos")]
#![expect(clippy::expect_used)]
#![expect(clippy::unwrap_used, clippy::expect_used)]
use std::collections::HashMap;
use std::sync::Arc;
use codex_core::exec::ExecParams;
use codex_core::exec::ExecToolCallOutput;
use codex_core::exec::SandboxType;
use codex_core::exec::process_exec_tool_call;
use codex_core::protocol::SandboxPolicy;
@@ -12,14 +13,20 @@ use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
use tempfile::TempDir;
use tokio::sync::Notify;
use codex_core::error::Result;
use codex_core::get_platform_sandbox;
async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
fn skip_test() -> bool {
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
return;
return true;
}
false
}
async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput> {
let sandbox_type = get_platform_sandbox().expect("should be able to get sandbox type");
assert_eq!(sandbox_type, SandboxType::MacosSeatbelt);
@@ -35,31 +42,82 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>, should_be_ok: bool) {
let ctrl_c = Arc::new(Notify::new());
let policy = SandboxPolicy::new_read_only_policy();
let result = process_exec_tool_call(params, sandbox_type, ctrl_c, &policy, &None, None).await;
assert!(result.is_ok() == should_be_ok);
process_exec_tool_call(params, sandbox_type, ctrl_c, &policy, &None, None).await
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn exit_code_0_succeeds() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["echo", "hello"];
run_test_cmd(tmp, cmd, true).await
let output = run_test_cmd(tmp, cmd).await.unwrap();
assert_eq!(output.stdout.text, "hello\n");
assert_eq!(output.stderr.text, "");
assert_eq!(output.stdout.truncated_after_lines, None);
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn truncates_output_lines() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["seq", "300"];
#[expect(clippy::unwrap_used)]
let output = run_test_cmd(tmp, cmd).await.unwrap();
let expected_output = (1..=256)
.map(|i| format!("{i}\n"))
.collect::<Vec<_>>()
.join("");
assert_eq!(output.stdout.text, expected_output);
assert_eq!(output.stdout.truncated_after_lines, Some(256));
}
/// Command succeeds with exit code 0 normally
#[tokio::test]
async fn truncates_output_bytes() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
// each line is 1000 bytes
let cmd = vec!["bash", "-lc", "seq 15 | awk '{printf \"%-1000s\\n\", $0}'"];
let output = run_test_cmd(tmp, cmd).await.unwrap();
assert_eq!(output.stdout.text.len(), 10240);
assert_eq!(output.stdout.truncated_after_lines, Some(10));
}
/// Command not found returns exit code 127, this is not considered a sandbox error
#[tokio::test]
async fn exit_command_not_found_is_ok() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let cmd = vec!["/bin/bash", "-c", "nonexistent_command_12345"];
run_test_cmd(tmp, cmd, true).await
run_test_cmd(tmp, cmd).await.unwrap();
}
/// Writing a file fails and should be considered a sandbox error
#[tokio::test]
async fn write_file_fails_as_sandbox_error() {
if skip_test() {
return;
}
let tmp = TempDir::new().expect("should be able to create temp dir");
let path = tmp.path().join("test.txt");
let cmd = vec![
@@ -67,5 +125,5 @@ async fn write_file_fails_as_sandbox_error() {
path.to_str().expect("should be able to get path"),
];
run_test_cmd(tmp, cmd, false).await;
assert!(run_test_cmd(tmp, cmd).await.is_err());
}

View File

@@ -76,7 +76,7 @@ async fn test_exec_stdout_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "hello-world\n");
assert_eq!(result.stdout.text, "hello-world\n");
let streamed = collect_stdout_events(rx);
// We should have received at least the same contents (possibly in one chunk)
@@ -128,8 +128,8 @@ async fn test_exec_stderr_stream_events_echo() {
};
assert_eq!(result.exit_code, 0);
assert_eq!(result.stdout, "");
assert_eq!(result.stderr, "oops\n");
assert_eq!(result.stdout.text, "");
assert_eq!(result.stderr.text, "oops\n");
// Collect only stderr delta events
let mut err = Vec::new();

View File

@@ -255,6 +255,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
call_id,
command,
cwd,
parsed_cmd: _,
}) => {
self.call_id_to_command.insert(
call_id.clone(),

View File

@@ -72,8 +72,8 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) {
.unwrap();
if res.exit_code != 0 {
println!("stdout:\n{}", res.stdout);
println!("stderr:\n{}", res.stderr);
println!("stdout:\n{}", res.stdout.text);
println!("stderr:\n{}", res.stderr.text);
panic!("exit code: {}", res.exit_code);
}
}
@@ -164,7 +164,7 @@ async fn assert_network_blocked(cmd: &[&str]) {
.await;
let (exit_code, stdout, stderr) = match result {
Ok(output) => (output.exit_code, output.stdout, output.stderr),
Ok(output) => (output.exit_code, output.stdout.text, output.stderr.text),
Err(CodexErr::Sandbox(SandboxErr::Denied(exit_code, stdout, stderr))) => {
(exit_code, stdout, stderr)
}

View File

@@ -34,7 +34,7 @@ pub struct CodexToolCallParam {
pub cwd: Option<String>,
/// Approval policy for shell commands generated by the model:
/// `untrusted`, `on-failure`, `never`.
/// `untrusted`, `on-failure`, `on-request`, `never`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<CodexToolCallApprovalPolicy>,
@@ -63,6 +63,7 @@ pub struct CodexToolCallParam {
pub enum CodexToolCallApprovalPolicy {
Untrusted,
OnFailure,
OnRequest,
Never,
}
@@ -71,6 +72,7 @@ impl From<CodexToolCallApprovalPolicy> for AskForApproval {
match value {
CodexToolCallApprovalPolicy::Untrusted => AskForApproval::UnlessTrusted,
CodexToolCallApprovalPolicy::OnFailure => AskForApproval::OnFailure,
CodexToolCallApprovalPolicy::OnRequest => AskForApproval::OnRequest,
CodexToolCallApprovalPolicy::Never => AskForApproval::Never,
}
}
@@ -244,10 +246,11 @@ mod tests {
"type": "object",
"properties": {
"approval-policy": {
"description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `never`.",
"description": "Approval policy for shell commands generated by the model: `untrusted`, `on-failure`, `on-request`, `never`.",
"enum": [
"untrusted",
"on-failure",
"on-request",
"never"
],
"type": "string"

View File

@@ -936,6 +936,7 @@ mod tests {
call_id: "c1".into(),
command: vec!["bash".into(), "-lc".into(), "echo hi".into()],
cwd: std::path::PathBuf::from("/work"),
parsed_cmd: vec![],
}),
};
@@ -947,7 +948,8 @@ mod tests {
"type": "exec_command_begin",
"call_id": "c1",
"command": ["bash", "-lc", "echo hi"],
"cwd": "/work"
"cwd": "/work",
"parsed_cmd": []
}
}
});

View File

@@ -366,6 +366,11 @@ impl App<'_> {
widget.add_diff_output(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();

View File

@@ -198,6 +198,12 @@ impl ChatComposer {
self.set_has_focus(has_focus);
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.textarea.insert_str(text);
self.sync_command_popup();
self.sync_file_search_popup();
}
/// Handle a key event coming from the main UI.
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
let result = match &mut self.active_popup {
@@ -698,14 +704,15 @@ impl WidgetRef for &ChatComposer {
let token_usage = &token_usage_info.total_token_usage;
hint.push(Span::from(" "));
hint.push(
Span::from(format!("{} tokens used", token_usage.total_tokens))
Span::from(format!("{} tokens used", token_usage.blended_total()))
.style(Style::default().add_modifier(Modifier::DIM)),
);
let last_token_usage = &token_usage_info.last_token_usage;
if let Some(context_window) = token_usage_info.model_context_window {
let percent_remaining: u8 = if context_window > 0 {
let percent = 100.0
- (last_token_usage.total_tokens as f32 / context_window as f32
- (last_token_usage.tokens_in_context_window() as f32
/ context_window as f32
* 100.0);
percent.clamp(0.0, 100.0) as u8
} else {
@@ -1077,6 +1084,46 @@ mod tests {
}
}
#[test]
fn slash_mention_dispatches_command_and_inserts_at() {
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 sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(true, sender, false);
for ch in ['/', 'm', 'e', 'n', 't', 'i', 'o', 'n'] {
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE));
}
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::None => {}
InputResult::Submitted(text) => {
panic!("expected command dispatch, but composer submitted literal text: {text}")
}
}
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")
}
}
assert_eq!(composer.textarea.text(), "@");
}
#[test]
fn test_multiple_pastes_submission() {
use crossterm::event::KeyCode;

View File

@@ -196,6 +196,11 @@ impl BottomPane<'_> {
}
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.composer.insert_str(text);
self.request_redraw();
}
/// Update the status indicator text. Prefer replacing the composer with
/// the StatusIndicatorView so the input pane shows a single-line status
/// like: `▌ Working waiting for model`.

View File

@@ -5,6 +5,7 @@ use std::sync::Arc;
use codex_core::codex_wrapper::CodexConversation;
use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config;
use codex_core::parse_command::ParsedCommand;
use codex_core::protocol::AgentMessageDeltaEvent;
use codex_core::protocol::AgentMessageEvent;
use codex_core::protocol::AgentReasoningDeltaEvent;
@@ -46,6 +47,7 @@ use crate::bottom_pane::BottomPaneParams;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::InputResult;
use crate::history_cell::CommandOutput;
use crate::history_cell::ExecCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::live_wrap::RowBuilder;
@@ -57,13 +59,14 @@ struct RunningCommand {
command: Vec<String>,
#[allow(dead_code)]
cwd: PathBuf,
parsed_cmd: Vec<ParsedCommand>,
}
pub(crate) struct ChatWidget<'a> {
app_event_tx: AppEventSender,
codex_op_tx: UnboundedSender<Op>,
bottom_pane: BottomPane<'a>,
active_history_cell: Option<HistoryCell>,
active_exec_cell: Option<HistoryCell>,
config: Config,
initial_user_message: Option<UserMessage>,
total_token_usage: TokenUsage,
@@ -112,7 +115,7 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
impl ChatWidget<'_> {
fn interrupt_running_task(&mut self) {
if self.bottom_pane.is_task_running() {
self.active_history_cell = None;
self.active_exec_cell = None;
self.bottom_pane.clear_ctrl_c_quit_hint();
self.submit_op(Op::Interrupt);
self.bottom_pane.set_task_running(false);
@@ -129,7 +132,7 @@ impl ChatWidget<'_> {
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
Layout::vertical([
Constraint::Max(
self.active_history_cell
self.active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(area.width)),
),
@@ -208,7 +211,7 @@ impl ChatWidget<'_> {
has_input_focus: true,
enhanced_keys_supported,
}),
active_history_cell: None,
active_exec_cell: None,
config,
initial_user_message: create_initial_user_message(
initial_prompt.unwrap_or_default(),
@@ -230,7 +233,7 @@ impl ChatWidget<'_> {
pub fn desired_height(&self, width: u16) -> u16 {
self.bottom_pane.desired_height(width)
+ self
.active_history_cell
.active_exec_cell
.as_ref()
.map_or(0, |c| c.desired_height(width))
}
@@ -253,6 +256,7 @@ impl ChatWidget<'_> {
}
fn add_to_history(&mut self, cell: HistoryCell) {
self.flush_active_exec_cell();
self.app_event_tx
.send(AppEvent::InsertHistory(cell.plain_lines()));
}
@@ -296,6 +300,16 @@ impl ChatWidget<'_> {
pub(crate) fn handle_codex_event(&mut self, event: Event) {
let Event { id, msg } = event;
match msg {
EventMsg::AgentMessageDelta(_)
| EventMsg::AgentReasoningDelta(_)
| EventMsg::ExecCommandOutputDelta(_) => {}
_ => {
tracing::info!("handle_codex_event: {:?}", msg);
}
}
match msg {
EventMsg::SessionConfigured(event) => {
self.bottom_pane
@@ -311,10 +325,12 @@ impl ChatWidget<'_> {
self.request_redraw();
}
EventMsg::AgentMessage(AgentMessageEvent { message: _ }) => {
// Final assistant answer: commit all remaining rows and close with
// a blank line. Use the final text if provided, otherwise rely on
// streamed deltas already in the builder.
EventMsg::AgentMessage(AgentMessageEvent { message }) => {
// AgentMessage: if no deltas were streamed, render the final text.
if self.current_stream != Some(StreamKind::Answer) && !message.is_empty() {
self.begin_stream(StreamKind::Answer);
self.stream_push_and_maybe_commit(&message);
}
self.finalize_stream(StreamKind::Answer);
self.request_redraw();
}
@@ -332,8 +348,12 @@ impl ChatWidget<'_> {
self.stream_push_and_maybe_commit(&delta);
self.request_redraw();
}
EventMsg::AgentReasoning(AgentReasoningEvent { text: _ }) => {
// Final reasoning: commit remaining rows and close with a blank.
EventMsg::AgentReasoning(AgentReasoningEvent { text }) => {
// Final reasoning: if no deltas were streamed, render the final text.
if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
self.begin_stream(StreamKind::Reasoning);
self.stream_push_and_maybe_commit(&text);
}
self.finalize_stream(StreamKind::Reasoning);
self.request_redraw();
}
@@ -346,8 +366,12 @@ impl ChatWidget<'_> {
self.stream_push_and_maybe_commit(&delta);
self.request_redraw();
}
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text: _ }) => {
// Finalize the raw reasoning stream just like the summarized reasoning event.
EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
// Final raw reasoning content: if no deltas were streamed, render the final text.
if self.current_stream != Some(StreamKind::Reasoning) && !text.is_empty() {
self.begin_stream(StreamKind::Reasoning);
self.stream_push_and_maybe_commit(&text);
}
self.finalize_stream(StreamKind::Reasoning);
self.request_redraw();
}
@@ -442,6 +466,7 @@ impl ChatWidget<'_> {
call_id,
command,
cwd,
parsed_cmd,
}) => {
self.finalize_active_stream();
// Ensure the status indicator is visible while the command runs.
@@ -452,9 +477,54 @@ impl ChatWidget<'_> {
RunningCommand {
command: command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
},
);
self.active_history_cell = Some(HistoryCell::new_active_exec_command(command));
let active_exec_cell = self.active_exec_cell.take();
let merge_result = merge_cells(&command, &parsed_cmd, &active_exec_cell);
self.active_exec_cell = match merge_result {
MergeResult::Merge(cell) => Some(cell),
MergeResult::Drop => active_exec_cell,
MergeResult::NewCell(cell) => {
if let Some(active) = active_exec_cell {
self.app_event_tx
.send(AppEvent::InsertHistory(active.plain_lines()));
}
Some(cell)
}
}
}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
exit_code,
duration: _,
stdout,
stderr,
}) => {
// Compute summary before moving stdout into the history cell.
let cmd = self.running_commands.remove(&call_id);
if let Some(cmd) = cmd {
// Preserve any merged parsed commands already present on the
// active cell; otherwise, fall back to this command's parsed.
let parsed_cmd = match &self.active_exec_cell {
Some(HistoryCell::Exec(ExecCell { parsed, .. })) if !parsed.is_empty() => {
parsed.clone()
}
_ => cmd.parsed_cmd.clone(),
};
// Replace the active running cell with the finalized result,
// but keep it as the active cell so it can be merged with
// subsequent commands before being committed.
self.active_exec_cell = Some(HistoryCell::new_completed_exec_command(
cmd.command,
parsed_cmd,
CommandOutput {
exit_code,
stdout,
stderr,
},
));
}
}
EventMsg::ExecCommandOutputDelta(_) => {
// TODO
@@ -474,31 +544,12 @@ impl ChatWidget<'_> {
self.add_to_history(HistoryCell::new_patch_apply_failure(event.stderr));
}
}
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
exit_code,
duration: _,
stdout,
stderr,
}) => {
// Compute summary before moving stdout into the history cell.
let cmd = self.running_commands.remove(&call_id);
self.active_history_cell = None;
self.add_to_history(HistoryCell::new_completed_exec_command(
cmd.map(|cmd| cmd.command).unwrap_or_else(|| vec![call_id]),
CommandOutput {
exit_code,
stdout,
stderr,
},
));
}
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
call_id: _,
invocation,
}) => {
self.finalize_active_stream();
self.add_to_history(HistoryCell::new_active_mcp_tool_call(invocation));
self.active_exec_cell = Some(HistoryCell::new_active_mcp_tool_call(invocation));
}
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
call_id: _,
@@ -506,7 +557,7 @@ impl ChatWidget<'_> {
invocation,
result,
}) => {
self.add_to_history(HistoryCell::new_completed_mcp_tool_call(
let completed = HistoryCell::new_completed_mcp_tool_call(
80,
invocation,
duration,
@@ -515,7 +566,8 @@ impl ChatWidget<'_> {
.map(|r| r.is_error.unwrap_or(false))
.unwrap_or(false),
result,
));
);
self.active_exec_cell = Some(completed);
}
EventMsg::GetHistoryEntryResponse(event) => {
let codex_core::protocol::GetHistoryEntryResponseEvent {
@@ -624,6 +676,10 @@ impl ChatWidget<'_> {
self.submit_user_message(text.into());
}
pub(crate) fn insert_str(&mut self, text: &str) {
self.bottom_pane.insert_str(text);
}
pub(crate) fn token_usage(&self) -> &TokenUsage {
&self.total_token_usage
}
@@ -659,11 +715,21 @@ impl ChatWidget<'_> {
// Ensure the waiting status is visible (composer replaced).
self.bottom_pane
.update_status_text("waiting for model".to_string());
self.flush_active_exec_cell();
self.emit_stream_header(kind);
}
}
fn flush_active_exec_cell(&mut self) {
if let Some(active) = self.active_exec_cell.take() {
self.app_event_tx
.send(AppEvent::InsertHistory(active.plain_lines()));
}
}
fn stream_push_and_maybe_commit(&mut self, delta: &str) {
self.flush_active_exec_cell();
self.live_builder.push_fragment(delta);
// Commit overflow rows (small batches) while keeping the last N rows visible.
@@ -745,7 +811,7 @@ 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);
if let Some(cell) = &self.active_history_cell {
if let Some(cell) = &self.active_exec_cell {
cell.render_ref(active_cell_area, buf);
}
}
@@ -778,3 +844,240 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
total_tokens: current_usage.total_tokens + new_usage.total_tokens,
}
}
enum MergeResult {
Merge(HistoryCell),
Drop,
NewCell(HistoryCell),
}
// Determine whether to and how to merge two consecutive exec cells.
fn merge_cells(
new_command: &[String],
new_parsed: &[ParsedCommand],
active_exec_cell: &Option<HistoryCell>,
) -> MergeResult {
let ExecCell {
command: _existing_command,
parsed: existing_parsed,
output: existing_output,
} = match active_exec_cell {
Some(HistoryCell::Exec(cell)) => cell,
_ => {
// There is no existing exec cell.
return MergeResult::NewCell(HistoryCell::new_active_exec_command(
new_command.to_vec(),
new_parsed.to_vec(),
));
}
};
let existing_last = existing_parsed.last();
let new_last = new_parsed.last();
// Drop the first command if it is a read and matches the last command.
// This is a common pattern the model does and it simplifies the output to dedupe.
let drop_first = if let (
Some(ParsedCommand::Read {
name: existing_name,
..
}),
Some(ParsedCommand::Read { name: new_name, .. }),
) = (existing_last, new_last)
{
existing_name == new_name
} else {
false
};
if drop_first && new_parsed.len() == 1 {
// There is only one command and it was deduped.
return MergeResult::Drop;
}
let existing_exit_code = existing_output.as_ref().map(|o| o.exit_code);
if let Some(code) = existing_exit_code {
if code != 0 {
// If the previous command failed, don't merge so the user can see stderr.
// Start a fresh cell for the new command instead of duplicating the old one.
return MergeResult::NewCell(HistoryCell::new_active_exec_command(
new_command.to_vec(),
new_parsed.to_vec(),
));
}
}
let mut merged_parsed = existing_parsed.to_vec();
if drop_first {
merged_parsed.extend(new_parsed[1..].to_vec());
} else {
merged_parsed.extend(new_parsed.to_vec());
}
MergeResult::Merge(HistoryCell::new_active_exec_command(
new_command.to_vec(),
merged_parsed,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::history_cell::CommandOutput;
fn read_cmd(name: &str) -> ParsedCommand {
ParsedCommand::Read {
cmd: vec!["cat".to_string(), name.to_string()],
name: name.to_string(),
}
}
fn unknown_cmd(cmd: &str) -> ParsedCommand {
ParsedCommand::Unknown {
cmd: cmd.split_whitespace().map(|s| s.to_string()).collect(),
}
}
#[test]
fn when_no_active_exec_cell_creates_new_cell() {
let new_command = vec!["echo".to_string(), "hi".to_string()];
let new_parsed = vec![read_cmd("a")];
let result = merge_cells(&new_command, &new_parsed, &None);
match result {
MergeResult::NewCell(cell) => match cell {
HistoryCell::Exec(ExecCell {
command,
parsed,
output,
}) => {
assert_eq!(command, new_command);
assert_eq!(parsed, new_parsed);
assert!(output.is_none());
}
_ => panic!("expected Exec cell"),
},
_ => panic!("expected NewCell"),
}
}
#[test]
fn drops_duplicate_trailing_read_when_new_has_only_one_read() {
// existing last = Read("foo"), new last = Read("foo"), new_parsed.len() == 1
let active = Some(HistoryCell::new_active_exec_command(
vec!["bash".into(), "-lc".into(), "cat foo".into()],
vec![read_cmd("foo")],
));
let new_command = vec!["cat".into(), "foo".into()];
let new_parsed = vec![read_cmd("foo")];
let result = merge_cells(&new_command, &new_parsed, &active);
match result {
MergeResult::Drop => {}
_ => panic!("expected Drop"),
}
}
#[test]
fn does_not_merge_when_previous_command_failed() {
// existing exit_code != 0 forces starting a fresh cell
let active = Some(HistoryCell::new_completed_exec_command(
vec!["bash".into(), "-lc".into(), "cat bar".into()],
vec![read_cmd("bar")],
CommandOutput {
exit_code: 1,
stdout: String::new(),
stderr: "err".into(),
},
));
// Ensure drop_first condition is false (different name)
let new_command = vec!["cat".into(), "baz".into()];
let new_parsed = vec![read_cmd("baz")];
let result = merge_cells(&new_command, &new_parsed, &active);
match result {
MergeResult::NewCell(cell) => match cell {
HistoryCell::Exec(ExecCell {
command, parsed, ..
}) => {
assert_eq!(command, new_command);
assert_eq!(parsed, new_parsed);
}
_ => panic!("expected Exec cell"),
},
_ => panic!("expected NewCell"),
}
}
#[test]
fn merges_with_drop_first_true_when_new_len_gt_one() {
// existing last Read("file.txt"), new starts with same Read then more
let active = Some(HistoryCell::new_active_exec_command(
vec!["cat".into(), "file.txt".into()],
vec![read_cmd("file.txt")],
));
let new_command = vec!["bash".into(), "-lc".into(), "sed -n 1,20p file.txt".into()];
// Place the duplicate Read as the LAST element to satisfy drop_first condition
let leading = unknown_cmd("tail -n 20");
let new_parsed = vec![leading.clone(), read_cmd("file.txt")];
let result = merge_cells(&new_command, &new_parsed, &active);
match result {
MergeResult::Merge(cell) => match cell {
HistoryCell::Exec(ExecCell {
command, parsed, ..
}) => {
assert_eq!(command, new_command);
// Expect existing parsed + new_parsed[1..]
assert_eq!(parsed.len(), 2);
match (&parsed[0], &parsed[1]) {
(
ParsedCommand::Read { name, .. },
ParsedCommand::Read { name: n2, .. },
) => {
assert_eq!(name, "file.txt");
assert_eq!(n2, "file.txt");
}
_ => panic!("unexpected parsed commands"),
}
}
_ => panic!("expected Exec cell"),
},
_ => panic!("expected Merge"),
}
}
#[test]
fn merges_without_drop_first_when_last_commands_differ() {
// existing last Read("file1.txt"), new last Read("file2.txt"); should concatenate
let active = Some(HistoryCell::new_active_exec_command(
vec!["cat".into(), "file1.txt".into()],
vec![read_cmd("file1.txt")],
));
let new_command = vec!["bash".into(), "-lc".into(), "cat file2.txt".into()];
let t2 = read_cmd("file2.txt");
let extra = unknown_cmd("echo done");
let new_parsed = vec![t2.clone(), extra.clone()];
let result = merge_cells(&new_command, &new_parsed, &active);
match result {
MergeResult::Merge(cell) => match cell {
HistoryCell::Exec(ExecCell {
command, parsed, ..
}) => {
assert_eq!(command, new_command);
assert_eq!(parsed.len(), 3);
match (&parsed[0], &parsed[1], &parsed[2]) {
(ParsedCommand::Read { name: n1, .. }, p2, p3) => {
assert_eq!(n1, "file1.txt");
assert_eq!(p2, &t2);
assert_eq!(p3, &extra);
}
_ => panic!("unexpected parsed commands"),
}
}
_ => panic!("expected Exec cell"),
},
_ => panic!("expected Merge"),
}
}
}

View File

@@ -0,0 +1,152 @@
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use std::collections::HashMap;
use std::path::PathBuf;
use codex_core::protocol::FileChange;
struct FileSummary {
display_path: String,
added: usize,
removed: usize,
}
pub(crate) fn create_diff_summary(
title: &str,
changes: HashMap<PathBuf, FileChange>,
) -> Vec<RtLine<'static>> {
let mut files: Vec<FileSummary> = Vec::new();
// Count additions/deletions from a unified diff body
let count_from_unified = |diff: &str| -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
let mut adds = 0usize;
let mut dels = 0usize;
for hunk in patch.hunks() {
for line in hunk.lines() {
match line {
diffy::Line::Insert(_) => adds += 1,
diffy::Line::Delete(_) => dels += 1,
_ => {}
}
}
}
(adds, dels)
} else {
let mut adds = 0usize;
let mut dels = 0usize;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => adds += 1,
Some(b'-') => dels += 1,
_ => {}
}
}
(adds, dels)
}
};
for (path, change) in &changes {
use codex_core::protocol::FileChange::*;
match change {
Add { content } => {
let added = content.lines().count();
files.push(FileSummary {
display_path: path.display().to_string(),
added,
removed: 0,
});
}
Delete => {
let removed = std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
.unwrap_or(0);
files.push(FileSummary {
display_path: path.display().to_string(),
added: 0,
removed,
});
}
Update {
unified_diff,
move_path,
} => {
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
format!("{}{}", path.display(), new_path.display())
} else {
path.display().to_string()
};
files.push(FileSummary {
display_path,
added,
removed,
});
}
}
}
let file_count = files.len();
let total_added: usize = files.iter().map(|f| f.added).sum();
let total_removed: usize = files.iter().map(|f| f.removed).sum();
let noun = if file_count == 1 { "file" } else { "files" };
let mut out: Vec<RtLine<'static>> = Vec::new();
// Header
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
header_spans.push(RtSpan::styled(
title.to_owned(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
header_spans.push(RtSpan::raw(" to "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(Color::Green),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(Color::Red),
));
header_spans.push(RtSpan::raw(")"));
out.push(RtLine::from(header_spans));
// Dimmed per-file lines with prefix
for (idx, f) in files.iter().enumerate() {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(f.display_path.clone()));
spans.push(RtSpan::raw(" ("));
spans.push(RtSpan::styled(
format!("+{}", f.added),
Style::default().fg(Color::Green),
));
spans.push(RtSpan::raw(" "));
spans.push(RtSpan::styled(
format!("-{}", f.removed),
Style::default().fg(Color::Red),
));
spans.push(RtSpan::raw(")"));
let mut line = RtLine::from(spans);
let prefix = if idx == 0 { "" } else { " " };
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
out
}

View File

@@ -1,3 +1,5 @@
use crate::colors::LIGHT_BLUE;
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::slash_command::SlashCommand;
@@ -8,6 +10,7 @@ use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
use codex_common::elapsed::format_duration;
use codex_core::config::Config;
use codex_core::parse_command::ParsedCommand;
use codex_core::plan_tool::PlanItemArg;
use codex_core::plan_tool::StepStatus;
use codex_core::plan_tool::UpdatePlanArgs;
@@ -26,8 +29,6 @@ use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
@@ -37,18 +38,13 @@ use std::path::PathBuf;
use std::time::Duration;
use tracing::error;
#[derive(Clone)]
pub(crate) struct CommandOutput {
pub(crate) exit_code: i32,
pub(crate) stdout: String,
pub(crate) stderr: String,
}
struct FileSummary {
display_path: String,
added: usize,
removed: usize,
}
pub(crate) enum PatchEventType {
ApprovalRequest,
ApplyBegin { auto_approved: bool },
@@ -69,28 +65,37 @@ fn line_to_static(line: &Line) -> Line<'static> {
}
}
pub(crate) struct ExecCell {
pub(crate) command: Vec<String>,
pub(crate) parsed: Vec<ParsedCommand>,
pub(crate) output: Option<CommandOutput>,
}
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
pub(crate) enum HistoryCell {
/// Welcome message.
WelcomeMessage { view: TextBlock },
WelcomeMessage {
view: TextBlock,
},
/// Message from the user.
UserPrompt { view: TextBlock },
UserPrompt {
view: TextBlock,
},
// AgentMessage and AgentReasoning variants were unused and have been removed.
/// An exec tool call that has not finished yet.
ActiveExecCommand { view: TextBlock },
/// Completed exec tool call.
CompletedExecCommand { view: TextBlock },
Exec(ExecCell),
/// An MCP tool call that has not finished yet.
ActiveMcpToolCall { view: TextBlock },
ActiveMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where we show the result serialized as JSON.
CompletedMcpToolCall { view: TextBlock },
CompletedMcpToolCall {
view: TextBlock,
},
/// Completed MCP tool call where the result is an image.
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
@@ -100,40 +105,60 @@ pub(crate) enum HistoryCell {
// resized version avoids doing the potentially expensive rescale twice
// because the scroll-view first calls `height()` for layouting and then
// `render_window()` for painting.
CompletedMcpToolCallWithImageOutput { _image: DynamicImage },
CompletedMcpToolCallWithImageOutput {
_image: DynamicImage,
},
/// Background event.
BackgroundEvent { view: TextBlock },
BackgroundEvent {
view: TextBlock,
},
/// Output from the `/diff` command.
GitDiffOutput { view: TextBlock },
GitDiffOutput {
view: TextBlock,
},
/// Output from the `/status` command.
StatusOutput { view: TextBlock },
StatusOutput {
view: TextBlock,
},
/// Output from the `/prompts` command.
PromptsOutput { view: TextBlock },
PromptsOutput {
view: TextBlock,
},
/// Error event from the backend.
ErrorEvent { view: TextBlock },
ErrorEvent {
view: TextBlock,
},
/// Info describing the newly-initialized session.
SessionInfo { view: TextBlock },
SessionInfo {
view: TextBlock,
},
/// A pending code patch that is awaiting user approval. Mirrors the
/// behaviour of `ActiveExecCommand` so the user sees *what* patch the
/// behaviour of `ExecCell` so the user sees *what* patch the
/// model wants to apply before being prompted to approve or deny it.
PendingPatch { view: TextBlock },
PendingPatch {
view: TextBlock,
},
/// A humanfriendly rendering of the model's current plan and step
/// statuses provided via the `update_plan` tool.
PlanUpdate { view: TextBlock },
PlanUpdate {
view: TextBlock,
},
/// Result of applying a patch (success or failure) with optional output.
PatchApplyResult { view: TextBlock },
PatchApplyResult {
view: TextBlock,
},
}
const TOOL_CALL_MAX_LINES: usize = 3;
const TOOL_CALL_MAX_LINES: usize = 5;
fn title_case(s: &str) -> String {
if s.is_empty() {
@@ -160,6 +185,7 @@ impl HistoryCell {
/// Return a cloned, plain representation of the cell's lines suitable for
/// oneshot insertion into the terminal scrollback. Image cells are
/// represented with a simple placeholder for now.
/// These lines are also rendered directly by ratatui wrapped in a Paragraph.
pub(crate) fn plain_lines(&self) -> Vec<Line<'static>> {
match self {
HistoryCell::WelcomeMessage { view }
@@ -170,15 +196,18 @@ impl HistoryCell {
| HistoryCell::PromptsOutput { view }
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
| HistoryCell::CompletedExecCommand { view }
| HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::PlanUpdate { view }
| HistoryCell::PatchApplyResult { view }
| HistoryCell::ActiveExecCommand { view, .. }
| HistoryCell::ActiveMcpToolCall { view, .. } => {
view.lines.iter().map(line_to_static).collect()
}
HistoryCell::Exec(ExecCell {
command,
parsed,
output,
}) => HistoryCell::exec_command_lines(command, parsed, output.as_ref()),
HistoryCell::CompletedMcpToolCallWithImageOutput { .. } => vec![
Line::from("tool result (image output omitted)"),
Line::from(""),
@@ -261,79 +290,104 @@ impl HistoryCell {
}
}
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
let command_escaped = strip_bash_lc_and_escape(&command);
pub(crate) fn new_active_exec_command(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
) -> Self {
HistoryCell::new_exec_cell(command, parsed, None)
}
let mut lines: Vec<Line<'static>> = Vec::new();
let mut iter = command_escaped.lines();
if let Some(first) = iter.next() {
lines.push(Line::from(vec![
"".cyan(),
"Running command ".magenta(),
first.to_string().into(),
]));
} else {
lines.push(Line::from(vec!["".cyan(), "Running command".magenta()]));
}
for cont in iter {
lines.push(Line::from(cont.to_string()));
}
lines.push(Line::from(""));
pub(crate) fn new_completed_exec_command(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
output: CommandOutput,
) -> Self {
HistoryCell::new_exec_cell(command, parsed, Some(output))
}
HistoryCell::ActiveExecCommand {
view: TextBlock::new(lines),
fn new_exec_cell(
command: Vec<String>,
parsed: Vec<ParsedCommand>,
output: Option<CommandOutput>,
) -> Self {
HistoryCell::Exec(ExecCell {
command,
parsed,
output,
})
}
fn exec_command_lines(
command: &[String],
parsed: &[ParsedCommand],
output: Option<&CommandOutput>,
) -> Vec<Line<'static>> {
match parsed.is_empty() {
true => HistoryCell::new_exec_command_generic(command, output),
false => HistoryCell::new_parsed_command(parsed, output),
}
}
pub(crate) fn new_completed_exec_command(command: Vec<String>, output: CommandOutput) -> Self {
let CommandOutput {
exit_code,
stdout,
stderr,
} = output;
fn new_parsed_command(
parsed_commands: &[ParsedCommand],
output: Option<&CommandOutput>,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line> = vec![Line::from("⚙︎ Working")];
for (i, parsed) in parsed_commands.iter().enumerate() {
let str = match parsed {
ParsedCommand::Read { name, .. } => format!("📖 {name}"),
ParsedCommand::ListFiles { cmd, path } => match path {
Some(p) => format!("📂 {p}"),
None => format!("📂 {}", shlex_join_safe(cmd)),
},
ParsedCommand::Search { query, path, cmd } => match (query, path) {
(Some(q), Some(p)) => format!("🔎 {q} in {p}"),
(Some(q), None) => format!("🔎 {q}"),
(None, Some(p)) => format!("🔎 {p}"),
(None, None) => format!("🔎 {}", shlex_join_safe(cmd)),
},
ParsedCommand::Format { .. } => "✨ Formatting".to_string(),
ParsedCommand::Test { cmd } => format!("🧪 {}", shlex_join_safe(cmd)),
ParsedCommand::Lint { cmd, .. } => format!("🧹 {}", shlex_join_safe(cmd)),
ParsedCommand::Unknown { cmd } => format!("⌨️ {}", shlex_join_safe(cmd)),
};
let prefix = if i == 0 { " L " } else { " " };
lines.push(Line::from(vec![
Span::styled(prefix, Style::default().add_modifier(Modifier::DIM)),
Span::styled(str, Style::default().fg(LIGHT_BLUE)),
]));
}
lines.extend(output_lines(output, true, false));
lines.push(Line::from(""));
lines
}
fn new_exec_command_generic(
command: &[String],
output: Option<&CommandOutput>,
) -> Vec<Line<'static>> {
let mut lines: Vec<Line<'static>> = Vec::new();
let command_escaped = strip_bash_lc_and_escape(&command);
let command_escaped = strip_bash_lc_and_escape(command);
let mut cmd_lines = command_escaped.lines();
if let Some(first) = cmd_lines.next() {
lines.push(Line::from(vec![
"⚡ Ran command ".magenta(),
"⚡ Running ".to_string().magenta(),
first.to_string().into(),
]));
} else {
lines.push(Line::from("⚡ Ran command".magenta()));
lines.push(Line::from("⚡ Running".to_string().magenta()));
}
for cont in cmd_lines {
lines.push(Line::from(cont.to_string()));
}
let src = if exit_code == 0 { stdout } else { stderr };
lines.extend(output_lines(output, false, true));
let mut lines_iter = src.lines();
for (idx, raw) in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if idx == 0 { "" } else { " " };
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
lines.push(line);
}
let remaining = lines_iter.count();
if remaining > 0 {
let mut more = Line::from(format!("... +{remaining} lines"));
// Continuation/ellipsis is treated as a subsequent line for prefixing
more.spans.insert(0, " ".into());
more.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
lines.push(more);
}
lines.push(Line::from(""));
HistoryCell::CompletedExecCommand {
view: TextBlock::new(lines),
}
lines
}
pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> Self {
@@ -773,7 +827,7 @@ impl HistoryCell {
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>,
) -> Self {
let title = match event_type {
let title = match &event_type {
PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin {
auto_approved: true,
@@ -791,15 +845,7 @@ impl HistoryCell {
}
};
let summary_lines = create_diff_summary(title, changes);
let mut lines: Vec<Line<'static>> = Vec::new();
for line in summary_lines {
lines.push(line);
}
lines.push(Line::from(""));
let lines: Vec<Line<'static>> = create_diff_summary(title, changes);
HistoryCell::PendingPatch {
view: TextBlock::new(lines),
@@ -813,17 +859,15 @@ impl HistoryCell {
lines.push(Line::from("✘ Failed to apply patch".magenta().bold()));
if !stderr.trim().is_empty() {
let mut iter = stderr.lines();
for (i, raw) in iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
let prefix = if i == 0 { "" } else { " " };
let s = format!("{prefix}{raw}");
lines.push(ansi_escape_line(&s).dim());
}
let remaining = iter.count();
if remaining > 0 {
lines.push(Line::from(""));
lines.push(Line::from(format!("... +{remaining} lines")).dim());
}
lines.extend(output_lines(
Some(&CommandOutput {
exit_code: 1,
stdout: String::new(),
stderr,
}),
true,
true,
));
}
lines.push(Line::from(""));
@@ -842,131 +886,58 @@ impl WidgetRef for &HistoryCell {
}
}
fn create_diff_summary(title: &str, changes: HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
let mut files: Vec<FileSummary> = Vec::new();
// Count additions/deletions from a unified diff body
let count_from_unified = |diff: &str| -> (usize, usize) {
if let Ok(patch) = diffy::Patch::from_str(diff) {
let mut adds = 0usize;
let mut dels = 0usize;
for hunk in patch.hunks() {
for line in hunk.lines() {
match line {
diffy::Line::Insert(_) => adds += 1,
diffy::Line::Delete(_) => dels += 1,
_ => {}
}
}
}
(adds, dels)
} else {
let mut adds = 0usize;
let mut dels = 0usize;
for l in diff.lines() {
if l.starts_with("+++") || l.starts_with("---") || l.starts_with("@@") {
continue;
}
match l.as_bytes().first() {
Some(b'+') => adds += 1,
Some(b'-') => dels += 1,
_ => {}
}
}
(adds, dels)
}
fn output_lines(
output: Option<&CommandOutput>,
only_err: bool,
include_angle_pipe: bool,
) -> Vec<Line<'static>> {
let CommandOutput {
exit_code,
stdout,
stderr,
} = match output {
Some(output) if only_err && output.exit_code == 0 => return vec![],
Some(output) => output,
None => return vec![],
};
for (path, change) in &changes {
use codex_core::protocol::FileChange::*;
match change {
Add { content } => {
let added = content.lines().count();
files.push(FileSummary {
display_path: path.display().to_string(),
added,
removed: 0,
});
}
Delete => {
let removed = std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
.unwrap_or(0);
files.push(FileSummary {
display_path: path.display().to_string(),
added: 0,
removed,
});
}
Update {
unified_diff,
move_path,
} => {
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
format!("{}{}", path.display(), new_path.display())
} else {
path.display().to_string()
};
files.push(FileSummary {
display_path,
added,
removed,
});
}
}
let src = if *exit_code == 0 { stdout } else { stderr };
let lines: Vec<&str> = src.lines().collect();
let total = lines.len();
let limit = TOOL_CALL_MAX_LINES;
let mut out = Vec::new();
let head_end = total.min(limit);
for (i, raw) in lines[..head_end].iter().enumerate() {
let mut line = ansi_escape_line(raw);
let prefix = if i == 0 && include_angle_pipe {
""
} else {
" "
};
line.spans.insert(0, prefix.into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
out.push(line);
}
let file_count = files.len();
let total_added: usize = files.iter().map(|f| f.added).sum();
let total_removed: usize = files.iter().map(|f| f.removed).sum();
let noun = if file_count == 1 { "file" } else { "files" };
// If we will ellipsize less than the limit, just show it.
let show_ellipsis = total > 2 * limit;
if show_ellipsis {
let omitted = total - 2 * limit;
out.push(Line::from(format!("… +{omitted} lines")));
}
let mut out: Vec<RtLine<'static>> = Vec::new();
// Header
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
header_spans.push(RtSpan::styled(
title.to_owned(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
));
header_spans.push(RtSpan::raw(" to "));
header_spans.push(RtSpan::raw(format!("{file_count} {noun} ")));
header_spans.push(RtSpan::raw("("));
header_spans.push(RtSpan::styled(
format!("+{total_added}"),
Style::default().fg(Color::Green),
));
header_spans.push(RtSpan::raw(" "));
header_spans.push(RtSpan::styled(
format!("-{total_removed}"),
Style::default().fg(Color::Red),
));
header_spans.push(RtSpan::raw(")"));
out.push(RtLine::from(header_spans));
// Dimmed per-file lines with prefix
for (idx, f) in files.iter().enumerate() {
let mut spans: Vec<RtSpan<'static>> = Vec::new();
spans.push(RtSpan::raw(f.display_path.clone()));
spans.push(RtSpan::raw(" ("));
spans.push(RtSpan::styled(
format!("+{}", f.added),
Style::default().fg(Color::Green),
));
spans.push(RtSpan::raw(" "));
spans.push(RtSpan::styled(
format!("-{}", f.removed),
Style::default().fg(Color::Red),
));
spans.push(RtSpan::raw(")"));
let mut line = RtLine::from(spans);
let prefix = if idx == 0 { "" } else { " " };
line.spans.insert(0, prefix.into());
let tail_start = if show_ellipsis {
total - limit
} else {
head_end
};
for raw in lines[tail_start..].iter() {
let mut line = ansi_escape_line(raw);
line.spans.insert(0, " ".into());
line.spans.iter_mut().for_each(|span| {
span.style = span.style.add_modifier(Modifier::DIM);
});
@@ -996,3 +967,10 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
];
Line::from(invocation_spans)
}
fn shlex_join_safe(command: &[String]) -> String {
match shlex::try_join(command.iter().map(|s| s.as_str())) {
Ok(cmd) => cmd,
Err(_) => command.join(" "),
}
}

View File

@@ -31,6 +31,7 @@ mod citation_regex;
mod cli;
mod colors;
pub mod custom_terminal;
mod diff_render;
mod exec_command;
mod file_search;
mod get_git_diff;

View File

@@ -16,6 +16,7 @@ pub enum SlashCommand {
Init,
Compact,
Diff,
Mention,
Status,
Prompts,
Logout,
@@ -33,6 +34,7 @@ impl SlashCommand {
SlashCommand::Compact => "summarize conversation to prevent hitting the context limit",
SlashCommand::Quit => "exit Codex",
SlashCommand::Diff => "show git diff (including untracked files)",
SlashCommand::Mention => "mention a file",
SlashCommand::Status => "show current session configuration and token usage",
SlashCommand::Prompts => "show example prompts",
SlashCommand::Logout => "log out of Codex",

View File

@@ -247,7 +247,7 @@ impl UserApprovalWidget<'_> {
match decision {
ReviewDecision::Approved => {
lines.push(Line::from(vec![
" ".fg(Color::Green),
" ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),
@@ -258,7 +258,7 @@ impl UserApprovalWidget<'_> {
}
ReviewDecision::ApprovedForSession => {
lines.push(Line::from(vec![
" ".fg(Color::Green),
" ".fg(Color::Green),
"You ".into(),
"approved".bold(),
" codex to run ".into(),