This commit is contained in:
Rai (Michael Pokorny)
2025-06-25 23:06:36 -07:00
parent f51d888c73
commit 697746788d
34 changed files with 668 additions and 370 deletions

View File

@@ -8,8 +8,8 @@ repos:
additional_dependencies: [PyYAML, toml, pydantic] additional_dependencies: [PyYAML, toml, pydantic]
files: ^agentydragon/tasks/.* files: ^agentydragon/tasks/.*
- id: cargo-build - id: cargo-build
name: Check Rust workspace builds name: Check Rust workspace and linux-sandbox compile
entry: bash -lc 'cd codex-rs && RUSTFLAGS="-D warnings" cargo build --workspace --locked' entry: bash -lc 'cd codex-rs && RUSTFLAGS="-D warnings" cargo build --workspace --locked --all-targets && cargo build -p codex-linux-sandbox --locked --all-targets'
language: system language: system
pass_filenames: false pass_filenames: false
require_serial: true require_serial: true

View File

@@ -4,14 +4,14 @@ use codex_cli::SeatbeltCommand;
use codex_cli::login::run_login_with_chatgpt; use codex_cli::login::run_login_with_chatgpt;
use codex_cli::proto; use codex_cli::proto;
use codex_common::CliConfigOverrides; use codex_common::CliConfigOverrides;
use codex_core::config::find_codex_home;
use codex_exec::Cli as ExecCli; use codex_exec::Cli as ExecCli;
use codex_tui::Cli as TuiCli; use codex_tui::Cli as TuiCli;
use serde::de::Error as SerdeError;
use std::io::ErrorKind;
use std::path::PathBuf; use std::path::PathBuf;
use std::{env, fs, process}; use std::{env, fs, process};
use std::io::ErrorKind; use toml::{self, Value, value::Table};
use toml::{self, value::Table, Value};
use serde::de::Error as SerdeError;
use codex_core::config::find_codex_home;
use uuid::Uuid; use uuid::Uuid;
use crate::proto::ProtoCli; use crate::proto::ProtoCli;
@@ -42,7 +42,8 @@ struct MultitoolCli {
fn parse_toml_value(raw: &str) -> Result<Value, toml::de::Error> { fn parse_toml_value(raw: &str) -> Result<Value, toml::de::Error> {
let wrapped = format!("_x_ = {raw}"); let wrapped = format!("_x_ = {raw}");
let table: Table = toml::from_str(&wrapped)?; let table: Table = toml::from_str(&wrapped)?;
table.get("_x_") table
.get("_x_")
.cloned() .cloned()
.ok_or_else(|| SerdeError::custom("missing sentinel")) .ok_or_else(|| SerdeError::custom("missing sentinel"))
} }
@@ -152,9 +153,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
} }
// Open in editor from $EDITOR or fall back to vi. // Open in editor from $EDITOR or fall back to vi.
let editor = env::var_os("EDITOR").unwrap_or_else(|| "vi".into()); let editor = env::var_os("EDITOR").unwrap_or_else(|| "vi".into());
let status = process::Command::new(editor) let status = process::Command::new(editor).arg(&config_path).status()?;
.arg(&config_path)
.status()?;
if !status.success() { if !status.success() {
std::process::exit(status.code().unwrap_or(1)); std::process::exit(status.code().unwrap_or(1));
} }
@@ -243,13 +242,15 @@ fn apply_override(root: &mut toml::Value, path: &str, value: toml::Value) {
} }
match current { match current {
toml::Value::Table(tbl) => { toml::Value::Table(tbl) => {
current = tbl.entry((*part).to_string()) current = tbl
.entry((*part).to_string())
.or_insert_with(|| toml::Value::Table(Table::new())); .or_insert_with(|| toml::Value::Table(Table::new()));
} }
_ => { _ => {
*current = toml::Value::Table(Table::new()); *current = toml::Value::Table(Table::new());
if let toml::Value::Table(tbl) = current { if let toml::Value::Table(tbl) = current {
current = tbl.entry((*part).to_string()) current = tbl
.entry((*part).to_string())
.or_insert_with(|| toml::Value::Table(Table::new())); .or_insert_with(|| toml::Value::Table(Table::new()));
} }
} }
@@ -268,7 +269,9 @@ mod tests {
#[test] #[test]
fn config_subcommands_help() { fn config_subcommands_help() {
let mut cmd = MultitoolCli::command(); let mut cmd = MultitoolCli::command();
let cfg = cmd.find_subcommand_mut("config").expect("config subcommand not found"); let cfg = cmd
.find_subcommand_mut("config")
.expect("config subcommand not found");
let mut buf = Vec::new(); let mut buf = Vec::new();
cfg.write_long_help(&mut buf).unwrap(); cfg.write_long_help(&mut buf).unwrap();
let help = String::from_utf8(buf).unwrap(); let help = String::from_utf8(buf).unwrap();

View File

@@ -2,8 +2,8 @@
/// This uses `CARGO_BIN_EXE_codex` to locate the compiled binary. /// This uses `CARGO_BIN_EXE_codex` to locate the compiled binary.
#[cfg(test)] #[cfg(test)]
mod cli_config { mod cli_config {
use std::process::Command;
use std::fs; use std::fs;
use std::process::Command;
use tempfile; use tempfile;
use toml; use toml;

View File

@@ -66,7 +66,11 @@ pub(crate) async fn stream_chat_completions(
} }
messages.push(json!({"role": role, "content": text})); messages.push(json!({"role": role, "content": text}));
} }
ResponseItem::FunctionCall { name, arguments, call_id } => { ResponseItem::FunctionCall {
name,
arguments,
call_id,
} => {
// Mark tool invocation in-flight // Mark tool invocation in-flight
pending_call = Some(call_id.clone()); pending_call = Some(call_id.clone());
messages.push(json!({ messages.push(json!({
@@ -79,7 +83,9 @@ pub(crate) async fn stream_chat_completions(
}] }]
})); }));
} }
ResponseItem::LocalShellCall { id, status, action, .. } => { ResponseItem::LocalShellCall {
id, status, action, ..
} => {
// Mark shell-call invocation in-flight by id // Mark shell-call invocation in-flight by id
if let Some(call_id) = id { if let Some(call_id) = id {
pending_call = Some(call_id.clone()); pending_call = Some(call_id.clone());

View File

@@ -48,11 +48,12 @@ impl Prompt {
match std::env::var("CODEX_BASE_INSTRUCTIONS_FILE") { match std::env::var("CODEX_BASE_INSTRUCTIONS_FILE") {
Ok(ref path) if !path.is_empty() && path != "-" => { Ok(ref path) if !path.is_empty() && path != "-" => {
// Override built-in prompt: read file or abort // Override built-in prompt: read file or abort
let contents = std::fs::read_to_string(path) let contents = std::fs::read_to_string(path).unwrap_or_else(|e| {
.unwrap_or_else(|e| panic!( panic!(
"failed to read CODEX_BASE_INSTRUCTIONS_FILE '{}': {e}", "failed to read CODEX_BASE_INSTRUCTIONS_FILE '{}': {e}",
path path
)); )
});
sections.push(contents); sections.push(contents);
} }
Ok(_) => { Ok(_) => {

View File

@@ -37,7 +37,7 @@ use crate::WireApi;
use crate::client::ModelClient; use crate::client::ModelClient;
use crate::client_common::Prompt; use crate::client_common::Prompt;
use crate::client_common::ResponseEvent; use crate::client_common::ResponseEvent;
use crate::config::{Config, AutoAllowPredicate}; use crate::config::{AutoAllowPredicate, Config};
use crate::config_types::ShellEnvironmentPolicy; use crate::config_types::ShellEnvironmentPolicy;
use crate::conversation_history::ConversationHistory; use crate::conversation_history::ConversationHistory;
use crate::error::CodexErr; use crate::error::CodexErr;
@@ -83,8 +83,10 @@ use crate::protocol::Submission;
use crate::protocol::TaskCompleteEvent; use crate::protocol::TaskCompleteEvent;
use crate::rollout::RolloutRecorder; use crate::rollout::RolloutRecorder;
use crate::safety::SafetyCheck; use crate::safety::SafetyCheck;
use crate::safety::{assess_command_safety, evaluate_auto_allow_predicates, get_platform_sandbox, AutoAllowVote};
use crate::safety::assess_patch_safety; use crate::safety::assess_patch_safety;
use crate::safety::{
AutoAllowVote, assess_command_safety, evaluate_auto_allow_predicates, get_platform_sandbox,
};
use crate::user_notification::UserNotification; use crate::user_notification::UserNotification;
use crate::util::backoff; use crate::util::backoff;

View File

@@ -452,9 +452,7 @@ impl Config {
.or(config_profile.approval_policy) .or(config_profile.approval_policy)
.or(cfg.approval_policy) .or(cfg.approval_policy)
.unwrap_or_else(AskForApproval::default), .unwrap_or_else(AskForApproval::default),
auto_allow: config_profile auto_allow: config_profile.auto_allow.unwrap_or(cfg.auto_allow),
.auto_allow
.unwrap_or(cfg.auto_allow),
sandbox_policy, sandbox_policy,
shell_environment_policy, shell_environment_policy,
disable_response_storage: config_profile disable_response_storage: config_profile
@@ -802,6 +800,7 @@ disable_response_storage = true
model_provider_id: "openai".to_string(), model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(), model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::Never, approval_policy: AskForApproval::Never,
auto_allow: Vec::new(),
sandbox_policy: SandboxPolicy::new_read_only_policy(), sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(), shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false, disable_response_storage: false,
@@ -844,6 +843,7 @@ disable_response_storage = true
model_provider_id: "openai-chat-completions".to_string(), model_provider_id: "openai-chat-completions".to_string(),
model_provider: fixture.openai_chat_completions_provider.clone(), model_provider: fixture.openai_chat_completions_provider.clone(),
approval_policy: AskForApproval::UnlessAllowListed, approval_policy: AskForApproval::UnlessAllowListed,
auto_allow: Vec::new(),
sandbox_policy: SandboxPolicy::new_read_only_policy(), sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(), shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: false, disable_response_storage: false,
@@ -901,6 +901,7 @@ disable_response_storage = true
model_provider_id: "openai".to_string(), model_provider_id: "openai".to_string(),
model_provider: fixture.openai_provider.clone(), model_provider: fixture.openai_provider.clone(),
approval_policy: AskForApproval::OnFailure, approval_policy: AskForApproval::OnFailure,
auto_allow: Vec::new(),
sandbox_policy: SandboxPolicy::new_read_only_policy(), sandbox_policy: SandboxPolicy::new_read_only_policy(),
shell_environment_policy: ShellEnvironmentPolicy::default(), shell_environment_policy: ShellEnvironmentPolicy::default(),
disable_response_storage: true, disable_response_storage: true,

View File

@@ -1,7 +1,7 @@
use serde::Deserialize; use serde::Deserialize;
use crate::protocol::AskForApproval;
use crate::config::AutoAllowPredicate; use crate::config::AutoAllowPredicate;
use crate::protocol::AskForApproval;
/// Collection of common configuration options that a user can define as a unit /// Collection of common configuration options that a user can define as a unit
/// in `config.toml`. /// in `config.toml`.

View File

@@ -129,7 +129,9 @@ fn default_composer_max_rows() -> usize {
/// Default editor: `$VISUAL`, then `$EDITOR`, falling back to `nvim`. /// Default editor: `$VISUAL`, then `$EDITOR`, falling back to `nvim`.
fn default_editor() -> String { fn default_editor() -> String {
std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "nvim".into()) std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "nvim".into())
} }
/// Default timeout in seconds for the second Ctrl+D confirmation to exit the TUI. /// Default timeout in seconds for the second Ctrl+D confirmation to exit the TUI.

View File

@@ -27,7 +27,9 @@ mod model_provider_info;
pub use model_provider_info::ModelProviderInfo; pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi; pub use model_provider_info::WireApi;
mod models; mod models;
pub use models::{ContentItem, ReasoningItemReasoningSummary, ResponseItem, FunctionCallOutputPayload}; pub use models::{
ContentItem, FunctionCallOutputPayload, ReasoningItemReasoningSummary, ResponseItem,
};
pub mod openai_api_key; pub mod openai_api_key;
mod openai_tools; mod openai_tools;
mod project_doc; mod project_doc;
@@ -37,4 +39,4 @@ mod safety;
mod user_notification; mod user_notification;
pub mod util; pub mod util;
pub use client_common::{model_supports_reasoning_summaries, Prompt}; pub use client_common::{Prompt, model_supports_reasoning_summaries};

View File

@@ -256,7 +256,8 @@ impl SandboxPolicy {
impl SandboxPolicy { impl SandboxPolicy {
/// Grant disk-write permission for the specified folder. /// Grant disk-write permission for the specified folder.
pub fn allow_disk_write_folder(&mut self, folder: std::path::PathBuf) { pub fn allow_disk_write_folder(&mut self, folder: std::path::PathBuf) {
self.permissions.push(SandboxPermission::DiskWriteFolder { folder }); self.permissions
.push(SandboxPermission::DiskWriteFolder { folder });
} }
/// Revoke any disk-write permission for the specified folder. /// Revoke any disk-write permission for the specified folder.

View File

@@ -6,11 +6,11 @@ use std::path::PathBuf;
use codex_apply_patch::ApplyPatchAction; use codex_apply_patch::ApplyPatchAction;
use codex_apply_patch::ApplyPatchFileChange; use codex_apply_patch::ApplyPatchFileChange;
use crate::config::AutoAllowPredicate;
use crate::exec::SandboxType; use crate::exec::SandboxType;
use crate::is_safe_command::is_known_safe_command; use crate::is_safe_command::is_known_safe_command;
use crate::protocol::AskForApproval; use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy; use crate::protocol::SandboxPolicy;
use crate::config::AutoAllowPredicate;
#[derive(Debug)] #[derive(Debug)]
pub enum SafetyCheck { pub enum SafetyCheck {
@@ -295,13 +295,21 @@ mod tests {
std::fs::set_permissions(&deny_script, perms2).unwrap(); std::fs::set_permissions(&deny_script, perms2).unwrap();
// Allow script should return Allow // Allow script should return Allow
let preds = vec![AutoAllowPredicate { script: allow_script.to_string_lossy().into() }]; let preds = vec![AutoAllowPredicate {
script: allow_script.to_string_lossy().into(),
}];
let vote = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds); let vote = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds);
assert_eq!(vote, AutoAllowVote::Allow); assert_eq!(vote, AutoAllowVote::Allow);
// Deny script takes precedence over allow // Deny script takes precedence over allow
let preds2 = vec![AutoAllowPredicate { script: deny_script.to_string_lossy().into() }, let preds2 = vec![
AutoAllowPredicate { script: allow_script.to_string_lossy().into() }]; AutoAllowPredicate {
script: deny_script.to_string_lossy().into(),
},
AutoAllowPredicate {
script: allow_script.to_string_lossy().into(),
},
];
let vote2 = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds2); let vote2 = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds2);
assert_eq!(vote2, AutoAllowVote::Deny); assert_eq!(vote2, AutoAllowVote::Deny);
@@ -336,9 +344,15 @@ mod tests {
// All scripts no-opinion or error yields NoOpinion // All scripts no-opinion or error yields NoOpinion
let preds = vec![ let preds = vec![
AutoAllowPredicate { script: noop_script.to_string_lossy().into() }, AutoAllowPredicate {
AutoAllowPredicate { script: unknown_script.to_string_lossy().into() }, script: noop_script.to_string_lossy().into(),
AutoAllowPredicate { script: error_script.to_string_lossy().into() }, },
AutoAllowPredicate {
script: unknown_script.to_string_lossy().into(),
},
AutoAllowPredicate {
script: error_script.to_string_lossy().into(),
},
]; ];
let vote = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds); let vote = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds);
assert_eq!(vote, AutoAllowVote::NoOpinion); assert_eq!(vote, AutoAllowVote::NoOpinion);
@@ -362,8 +376,12 @@ mod tests {
std::fs::set_permissions(&allow_script, perms2).unwrap(); std::fs::set_permissions(&allow_script, perms2).unwrap();
let preds = vec![ let preds = vec![
AutoAllowPredicate { script: noop_script.to_string_lossy().into() }, AutoAllowPredicate {
AutoAllowPredicate { script: allow_script.to_string_lossy().into() }, script: noop_script.to_string_lossy().into(),
},
AutoAllowPredicate {
script: allow_script.to_string_lossy().into(),
},
]; ];
let vote = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds); let vote = evaluate_auto_allow_predicates(&["cmd".to_string()], &preds);
assert_eq!(vote, AutoAllowVote::Allow); assert_eq!(vote, AutoAllowVote::Allow);

View File

@@ -1,8 +1,8 @@
use serde_json::{json, Value}; use codex_core::{ContentItem, FunctionCallOutputPayload, ResponseItem};
use codex_core::{Prompt, ResponseItem, ContentItem, FunctionCallOutputPayload}; use serde_json::{Value, json};
/// Reproduce the `messages` JSON construction from `stream_chat_completions` /// Reproduce the `messages` JSON construction from `stream_chat_completions`
fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> { fn build_messages(input: Vec<ResponseItem>, _model: &str) -> Vec<Value> {
let mut messages = Vec::new(); let mut messages = Vec::new();
let mut pending = None::<String>; let mut pending = None::<String>;
let mut buf_user = Vec::new(); let mut buf_user = Vec::new();
@@ -30,7 +30,11 @@ fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> {
} }
messages.push(json!({"role": role, "content": text})); messages.push(json!({"role": role, "content": text}));
} }
ResponseItem::FunctionCall { name, arguments, call_id } => { ResponseItem::FunctionCall {
name,
arguments,
call_id,
} => {
pending = Some(call_id.clone()); pending = Some(call_id.clone());
messages.push(json!({ messages.push(json!({
"role": "assistant", "content": null, "role": "assistant", "content": null,
@@ -38,7 +42,9 @@ fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> {
})); }));
} }
ResponseItem::FunctionCallOutput { call_id, output } => { ResponseItem::FunctionCallOutput { call_id, output } => {
messages.push(json!({"role": "tool", "tool_call_id": call_id, "content": output.content})); messages.push(
json!({"role": "tool", "tool_call_id": call_id, "content": output.content}),
);
if pending.as_ref() == Some(&call_id) { if pending.as_ref() == Some(&call_id) {
pending = None; pending = None;
for m in buf_user.drain(..) { for m in buf_user.drain(..) {
@@ -52,7 +58,8 @@ fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> {
// cancellation: no output arrived // cancellation: no output arrived
if let Some(call_id) = pending { if let Some(call_id) = pending {
messages.push(json!({"role": "tool", "tool_call_id": call_id, "content": "Tool cancelled"})); messages
.push(json!({"role": "tool", "tool_call_id": call_id, "content": "Tool cancelled"}));
for m in buf_user.drain(..) { for m in buf_user.drain(..) {
messages.push(m); messages.push(m);
} }
@@ -63,36 +70,82 @@ fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> {
#[test] #[test]
fn normal_flow_no_buffer() { fn normal_flow_no_buffer() {
let input = vec![ResponseItem::Message { role: "user".into(), content: vec![ContentItem::InputText { text: "hi".into() }] }]; let input = vec![ResponseItem::Message {
role: "user".into(),
content: vec![ContentItem::InputText { text: "hi".into() }],
}];
let msgs = build_messages(input, "m1"); let msgs = build_messages(input, "m1");
assert_eq!(msgs.iter().filter(|m| m["role"] == json!("user")).count(), 1); assert_eq!(
msgs.iter().filter(|m| m["role"] == json!("user")).count(),
1
);
} }
#[test] #[test]
fn buffer_and_flush_on_output() { fn buffer_and_flush_on_output() {
let call_id = "c1".to_string(); let call_id = "c1".to_string();
let input = vec![ let input = vec![
ResponseItem::FunctionCall { name: "f".into(), arguments: "{}".into(), call_id: call_id.clone() }, ResponseItem::FunctionCall {
ResponseItem::Message { role: "user".into(), content: vec![ContentItem::InputText { text: "late".into() }] }, name: "f".into(),
ResponseItem::FunctionCallOutput { call_id: call_id.clone(), output: FunctionCallOutputPayload { content: "ok".into(), success: None } }, arguments: "{}".into(),
call_id: call_id.clone(),
},
ResponseItem::Message {
role: "user".into(),
content: vec![ContentItem::InputText {
text: "late".into(),
}],
},
ResponseItem::FunctionCallOutput {
call_id: call_id.clone(),
output: FunctionCallOutputPayload {
content: "ok".into(),
success: None,
},
},
]; ];
let msgs = build_messages(input, "m1"); let msgs = build_messages(input, "m1");
// order: system, functioncall, tool output, then buffered user // order: system, functioncall, tool output, then buffered user
let roles: Vec<_> = msgs.iter().map(|m| m["role"].clone()).collect(); let roles: Vec<_> = msgs.iter().map(|m| m["role"].clone()).collect();
assert_eq!(roles.as_slice(), &[json!("system"), json!("assistant"), json!("tool"), json!("user")]); assert_eq!(
roles.as_slice(),
&[
json!("system"),
json!("assistant"),
json!("tool"),
json!("user")
]
);
} }
#[test] #[test]
fn buffer_and_cancel() { fn buffer_and_cancel() {
let call_id = "c2".to_string(); let call_id = "c2".to_string();
let input = vec![ let input = vec![
ResponseItem::FunctionCall { name: "f".into(), arguments: "{}".into(), call_id: call_id.clone() }, ResponseItem::FunctionCall {
ResponseItem::Message { role: "user".into(), content: vec![ContentItem::InputText { text: "oops".into() }] }, name: "f".into(),
arguments: "{}".into(),
call_id: call_id.clone(),
},
ResponseItem::Message {
role: "user".into(),
content: vec![ContentItem::InputText {
text: "oops".into(),
}],
},
]; ];
let msgs = build_messages(input, "m1"); let msgs = build_messages(input, "m1");
// expect system, functioncall, fake cancel, then user // expect system, functioncall, fake cancel, then user
let roles: Vec<_> = msgs.iter().map(|m| m["role"].clone()).collect(); let roles: Vec<_> = msgs.iter().map(|m| m["role"].clone()).collect();
assert_eq!(roles.as_slice(), &[json!("system"), json!("assistant"), json!("tool"), json!("user")]); assert_eq!(
roles.as_slice(),
&[
json!("system"),
json!("assistant"),
json!("tool"),
json!("user")
]
);
// cancellation message content // cancellation message content
assert_eq!(msgs[2]["content"], json!("Tool cancelled")); assert_eq!(msgs[2]["content"], json!("Tool cancelled"));
} }

View File

@@ -5,8 +5,6 @@ use std::ffi::CString;
use libc; use libc;
use crate::landlock::apply_sandbox_policy_to_current_thread; use crate::landlock::apply_sandbox_policy_to_current_thread;
use codex_core::config::{Config, ConfigOverrides};
use codex_core::util::{find_git_root, relative_path_from_git_root};
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
pub struct LandlockCommand { pub struct LandlockCommand {
@@ -26,48 +24,11 @@ pub fn run_main() -> ! {
None => codex_core::protocol::SandboxPolicy::new_read_only_policy(), None => codex_core::protocol::SandboxPolicy::new_read_only_policy(),
}; };
// Determine working directory inside the session, possibly auto-mounting the repo. // Determine working directory inside the session.
let mut cwd = match std::env::current_dir() { let mut cwd = match std::env::current_dir() {
Ok(cwd) => cwd, Ok(cwd) => cwd,
Err(e) => panic!("failed to getcwd(): {e:?}"), Err(e) => panic!("failed to getcwd(): {e:?}"),
}; };
// Load configuration to check auto_mount_repo flag
let config = match codex_core::config::Config::load_with_cli_overrides(
Vec::new(),
codex_core::config::ConfigOverrides::default(),
) {
Ok(cfg) => cfg,
Err(e) => panic!("failed to load config for auto-mount: {e:?}"),
};
if config.tui.auto_mount_repo {
if let Some(root) = codex_core::util::find_git_root(&cwd) {
// Compute relative subpath
let rel = codex_core::util::relative_path_from_git_root(&cwd).unwrap_or_default();
let mount_prefix = std::path::PathBuf::from(&config.tui.mount_prefix);
// Create mount target
std::fs::create_dir_all(&mount_prefix).unwrap_or_else(|e| {
panic!("failed to create mount prefix {mount_prefix:?}: {e:?}")
});
// Bind-mount repository root into session
let src = std::ffi::CString::new(root.to_string_lossy().as_ref())
.expect("invalid git root path");
let dst = std::ffi::CString::new(mount_prefix.to_string_lossy().as_ref())
.expect("invalid mount prefix path");
unsafe {
libc::mount(
src.as_ptr(),
dst.as_ptr(),
std::ptr::null(),
libc::MS_BIND,
std::ptr::null(),
);
}
// Change working directory to corresponding subfolder under mount
cwd = mount_prefix.join(rel);
std::env::set_current_dir(&cwd)
.unwrap_or_else(|e| panic!("failed to chdir to {cwd:?}: {e:?}"));
}
}
if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &cwd) { if let Err(e) = apply_sandbox_policy_to_current_thread(&sandbox_policy, &cwd) {
panic!("error running landlock: {e:?}"); panic!("error running landlock: {e:?}");

View File

@@ -1,7 +1,7 @@
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::confirm_ctrl_d::ConfirmCtrlD;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget; use crate::chatwidget::ChatWidget;
use crate::confirm_ctrl_d::ConfirmCtrlD;
use crate::git_warning_screen::GitWarningOutcome; use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen; use crate::git_warning_screen::GitWarningScreen;
use crate::login_screen::LoginScreen; use crate::login_screen::LoginScreen;
@@ -69,14 +69,18 @@ struct ChatWidgetArgs {
} }
/// Parse raw argument string for `/mount-add host=... container=... mode=...`. /// Parse raw argument string for `/mount-add host=... container=... mode=...`.
fn parse_mount_add_args(raw: &str) -> Result<(std::path::PathBuf, std::path::PathBuf, String), String> { fn parse_mount_add_args(
raw: &str,
) -> Result<(std::path::PathBuf, std::path::PathBuf, String), String> {
let mut host = None; let mut host = None;
let mut container = None; let mut container = None;
let mut mode = "rw".to_string(); let mut mode = "rw".to_string();
for token in raw.split_whitespace() { for token in raw.split_whitespace() {
let mut parts = token.splitn(2, '='); let mut parts = token.splitn(2, '=');
let key = parts.next().unwrap(); let key = parts.next().unwrap();
let value = parts.next().ok_or_else(|| format!("invalid argument '{}'", token))?; let value = parts
.next()
.ok_or_else(|| format!("invalid argument '{}'", token))?;
match key { match key {
"host" => host = Some(std::path::PathBuf::from(value)), "host" => host = Some(std::path::PathBuf::from(value)),
"container" => container = Some(std::path::PathBuf::from(value)), "container" => container = Some(std::path::PathBuf::from(value)),
@@ -95,7 +99,9 @@ fn parse_mount_remove_args(raw: &str) -> Result<std::path::PathBuf, String> {
for token in raw.split_whitespace() { for token in raw.split_whitespace() {
let mut parts = token.splitn(2, '='); let mut parts = token.splitn(2, '=');
let key = parts.next().unwrap(); let key = parts.next().unwrap();
let value = parts.next().ok_or_else(|| format!("invalid argument '{}'", token))?; let value = parts
.next()
.ok_or_else(|| format!("invalid argument '{}'", token))?;
if key == "container" { if key == "container" {
container = Some(std::path::PathBuf::from(value)); container = Some(std::path::PathBuf::from(value));
} else { } else {
@@ -347,15 +353,19 @@ impl<'a> App<'a> {
let _ = child.wait(); let _ = child.wait();
} }
Err(err) => { Err(err) => {
let _ = tx.send(AppEvent::LatestLog( let _ = tx.send(AppEvent::LatestLog(format!(
format!("Failed to spawn inspect-env: {err}") "Failed to spawn inspect-env: {err}"
)); )));
} }
} }
let _ = tx.send(AppEvent::Redraw); let _ = tx.send(AppEvent::Redraw);
}); });
} }
AppEvent::MountAdd { host, container, mode } => { AppEvent::MountAdd {
host,
container,
mode,
} => {
if let Err(err) = do_mount_add(&mut self.config, &host, &container, &mode) { if let Err(err) = do_mount_add(&mut self.config, &host, &container, &mode) {
tracing::error!("mount-add failed: {err}"); tracing::error!("mount-add failed: {err}");
} }
@@ -405,22 +415,22 @@ impl<'a> App<'a> {
} }
} }
} }
KeyEvent { KeyEvent {
code: KeyCode::Char('d'), code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL, modifiers: crossterm::event::KeyModifiers::CONTROL,
.. ..
} => { } => {
// Handle Ctrl+D exit confirmation when enabled. // Handle Ctrl+D exit confirmation when enabled.
let now = Instant::now(); let now = Instant::now();
if self.confirm_ctrl_d.handle(now) { if self.confirm_ctrl_d.handle(now) {
break; break;
}
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_exit_confirmation_prompt(
"Press Ctrl+D again to confirm exit".to_string(),
);
}
} }
if let AppState::Chat { widget } = &mut self.app_state {
widget.show_exit_confirmation_prompt(
"Press Ctrl+D again to confirm exit".to_string(),
);
}
}
_ => { _ => {
self.dispatch_key_event(key_event); self.dispatch_key_event(key_event);
} }
@@ -482,7 +492,9 @@ impl<'a> App<'a> {
if let AppState::Chat { widget } = &mut self.app_state { if let AppState::Chat { widget } = &mut self.app_state {
widget.push_inspect_env(); widget.push_inspect_env();
} }
let _ = self.app_event_tx.send(AppEvent::InlineInspectEnv(String::new())); let _ = self
.app_event_tx
.send(AppEvent::InlineInspectEnv(String::new()));
} }
SlashCommand::Shell => { SlashCommand::Shell => {
if let AppState::Chat { widget } = &mut self.app_state { if let AppState::Chat { widget } = &mut self.app_state {
@@ -496,8 +508,13 @@ impl<'a> App<'a> {
widget.handle_shell_command(cmd); widget.handle_shell_command(cmd);
self.app_event_tx.send(AppEvent::Redraw); self.app_event_tx.send(AppEvent::Redraw);
} }
}, }
AppEvent::ShellCommandResult { call_id, stdout, stderr, exit_code } => { AppEvent::ShellCommandResult {
call_id,
stdout,
stderr,
exit_code,
} => {
if let AppState::Chat { widget } = &mut self.app_state { if let AppState::Chat { widget } = &mut self.app_state {
widget.handle_shell_command_result(call_id, stdout, stderr, exit_code); widget.handle_shell_command_result(call_id, stdout, stderr, exit_code);
self.app_event_tx.send(AppEvent::Redraw); self.app_event_tx.send(AppEvent::Redraw);

View File

@@ -63,3 +63,54 @@ pub(crate) enum AppEvent {
exit_code: i32, exit_code: i32,
}, },
} }
impl PartialEq for AppEvent {
fn eq(&self, other: &Self) -> bool {
use AppEvent::*;
match (self, other) {
(CodexEvent(_), CodexEvent(_)) => true,
(Redraw, Redraw) => true,
(KeyEvent(a), KeyEvent(b)) => a == b,
(Scroll(a), Scroll(b)) => a == b,
(ExitRequest, ExitRequest) => true,
(CodexOp(a), CodexOp(b)) => a == b,
(LatestLog(a), LatestLog(b)) => a == b,
(DispatchCommand(a), DispatchCommand(b)) => a == b,
(InlineMountAdd(a), InlineMountAdd(b)) => a == b,
(InlineMountRemove(a), InlineMountRemove(b)) => a == b,
(InlineInspectEnv(a), InlineInspectEnv(b)) => a == b,
(
MountAdd {
host: h1,
container: c1,
mode: m1,
},
MountAdd {
host: h2,
container: c2,
mode: m2,
},
) => h1 == h2 && c1 == c2 && m1 == m2,
(MountRemove { container: c1 }, MountRemove { container: c2 }) => c1 == c2,
(ConfigReloadRequest(a), ConfigReloadRequest(b)) => a == b,
(ConfigReloadApply, ConfigReloadApply) => true,
(ConfigReloadIgnore, ConfigReloadIgnore) => true,
(ShellCommand(a), ShellCommand(b)) => a == b,
(
ShellCommandResult {
call_id: i1,
stdout: o1,
stderr: e1,
exit_code: x1,
},
ShellCommandResult {
call_id: i2,
stdout: o2,
stderr: e2,
exit_code: x2,
},
) => i1 == i2 && o1 == o2 && e1 == e2 && x1 == x2,
_ => false,
}
}
}

View File

@@ -172,7 +172,12 @@ impl ChatComposer<'_> {
} }
(InputResult::None, true) (InputResult::None, true)
} }
Input { key: Key::Enter, shift: false, alt: false, ctrl: false } => { Input {
key: Key::Enter,
shift: false,
alt: false,
ctrl: false,
} => {
if let Some(cmd) = popup.selected_command() { if let Some(cmd) = popup.selected_command() {
// Inline DSL for mount-add/remove with args or dispatch other commands. // Inline DSL for mount-add/remove with args or dispatch other commands.
let first_line = self let first_line = self
@@ -181,7 +186,10 @@ impl ChatComposer<'_> {
.first() .first()
.map(|s| s.as_str()) .map(|s| s.as_str())
.unwrap_or(""); .unwrap_or("");
let stripped = first_line.trim_start().strip_prefix('/').unwrap_or(first_line); let stripped = first_line
.trim_start()
.strip_prefix('/')
.unwrap_or(first_line);
let mut parts = stripped.splitn(2, char::is_whitespace); let mut parts = stripped.splitn(2, char::is_whitespace);
let _cmd_token = parts.next().unwrap_or(""); let _cmd_token = parts.next().unwrap_or("");
let args = parts.next().unwrap_or("").trim_start(); let args = parts.next().unwrap_or("").trim_start();
@@ -191,7 +199,9 @@ impl ChatComposer<'_> {
self.command_popup = None; self.command_popup = None;
return (InputResult::None, true); return (InputResult::None, true);
} }
if !args.is_empty() && (*cmd == SlashCommand::MountAdd || *cmd == SlashCommand::MountRemove) { if !args.is_empty()
&& (*cmd == SlashCommand::MountAdd || *cmd == SlashCommand::MountRemove)
{
let ev = if *cmd == SlashCommand::MountAdd { let ev = if *cmd == SlashCommand::MountAdd {
AppEvent::InlineMountAdd(args.to_string()) AppEvent::InlineMountAdd(args.to_string())
} else { } else {
@@ -261,15 +271,28 @@ impl ChatComposer<'_> {
(InputResult::Submitted(text), true) (InputResult::Submitted(text), true)
} }
} }
Input { key: Key::Enter, .. } Input {
| Input { key: Key::Char('j'), ctrl: true, alt: false, shift: false } => { key: Key::Enter, ..
}
| Input {
key: Key::Char('j'),
ctrl: true,
alt: false,
shift: false,
} => {
self.textarea.insert_newline(); self.textarea.insert_newline();
(InputResult::None, true) (InputResult::None, true)
} }
Input { key: Key::Char('m'), ctrl: true, alt: false, shift: false } => { Input {
key: Key::Char('m'),
ctrl: true,
alt: false,
shift: false,
} => {
// Toggle shell-command mode and prompt/exit accordingly // Toggle shell-command mode and prompt/exit accordingly
self.shell_mode = !self.shell_mode; self.shell_mode = !self.shell_mode;
self.app_event_tx.send(AppEvent::DispatchCommand(SlashCommand::Shell)); self.app_event_tx
.send(AppEvent::DispatchCommand(SlashCommand::Shell));
(InputResult::None, true) (InputResult::None, true)
} }
input => self.handle_input_basic(input), input => self.handle_input_basic(input),
@@ -302,7 +325,9 @@ impl ChatComposer<'_> {
} }
let path = tmp.path(); let path = tmp.path();
// Determine editor: VISUAL > EDITOR > nvim // Determine editor: VISUAL > EDITOR > nvim
let editor = std::env::var("VISUAL").or_else(|_| std::env::var("EDITOR")).unwrap_or_else(|_| "nvim".into()); let editor = std::env::var("VISUAL")
.or_else(|_| std::env::var("EDITOR"))
.unwrap_or_else(|_| "nvim".into());
// Launch editor and wait for exit // Launch editor and wait for exit
if let Err(e) = Command::new(editor).arg(path).status() { if let Err(e) = Command::new(editor).arg(path).status() {
tracing::error!("failed to launch editor: {e}"); tracing::error!("failed to launch editor: {e}");
@@ -381,10 +406,8 @@ impl ChatComposer<'_> {
let bs = if self.shell_mode { let bs = if self.shell_mode {
BlockState { BlockState {
right_title: Line::from( right_title: Line::from("Shell mode Enter to run | Ctrl+M to exit shell mode")
"Shell mode Enter to run | Ctrl+M to exit shell mode", .alignment(Alignment::Right),
)
.alignment(Alignment::Right),
border_style: Style::default().fg(Color::Red), border_style: Style::default().fg(Color::Red),
} }
} else if has_focus { } else if has_focus {
@@ -451,7 +474,12 @@ impl WidgetRef for &ChatComposer<'_> {
} else { } else {
Color::Red Color::Red
}; };
buf.set_string(area.x + 1, area.y + area.height - 1, text, Style::default().fg(color)); buf.set_string(
area.x + 1,
area.y + area.height - 1,
text,
Style::default().fg(color),
);
} }
} }
} }

View File

@@ -1,12 +1,12 @@
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
use ratatui::prelude::Widget; use ratatui::prelude::Widget;
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use super::{BottomPane, BottomPaneView};
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender; use crate::app_event_sender::AppEventSender;
use super::{BottomPane, BottomPaneView};
/// BottomPane view displaying the diff and prompting to apply or ignore. /// BottomPane view displaying the diff and prompting to apply or ignore.
pub(crate) struct ConfigReloadView { pub(crate) struct ConfigReloadView {
@@ -18,7 +18,11 @@ pub(crate) struct ConfigReloadView {
impl ConfigReloadView { impl ConfigReloadView {
/// Create a new view with the unified diff of config changes. /// Create a new view with the unified diff of config changes.
pub fn new(diff: String, app_event_tx: AppEventSender) -> Self { pub fn new(diff: String, app_event_tx: AppEventSender) -> Self {
Self { diff, app_event_tx, done: false } Self {
diff,
app_event_tx,
done: false,
}
} }
} }
@@ -51,7 +55,9 @@ impl<'a> BottomPaneView<'a> for ConfigReloadView {
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.title("Config changed (Enter=Apply Esc=Ignore)"); .title("Config changed (Enter=Apply Esc=Ignore)");
Paragraph::new(self.diff.clone()).block(block).render(area, buf); Paragraph::new(self.diff.clone())
.block(block)
.render(area, buf);
} }
fn should_hide_when_task_is_done(&mut self) -> bool { fn should_hide_when_task_is_done(&mut self) -> bool {

View File

@@ -1,11 +1,11 @@
use crossterm::event::{KeyCode, KeyEvent}; use crossterm::event::{KeyCode, KeyEvent};
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
use ratatui::prelude::Widget; use ratatui::prelude::Widget;
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use super::{BottomPane, BottomPaneView};
use super::bottom_pane_view::ConditionalUpdate; use super::bottom_pane_view::ConditionalUpdate;
use super::{BottomPane, BottomPaneView};
/// View for displaying the output of `codex inspect-env` in the bottom pane. /// View for displaying the output of `codex inspect-env` in the bottom pane.
pub(crate) struct InspectEnvView { pub(crate) struct InspectEnvView {
@@ -16,7 +16,10 @@ pub(crate) struct InspectEnvView {
impl InspectEnvView { impl InspectEnvView {
/// Create a new inspect-env view. /// Create a new inspect-env view.
pub fn new() -> Self { pub fn new() -> Self {
Self { lines: Vec::new(), done: false } Self {
lines: Vec::new(),
done: false,
}
} }
} }
@@ -71,14 +74,19 @@ mod tests {
let mut view = InspectEnvView::new(); let mut view = InspectEnvView::new();
view.update_status_text("line1".to_string()); view.update_status_text("line1".to_string());
view.update_status_text("line2".to_string()); view.update_status_text("line2".to_string());
let area = Rect { x: 0, y: 0, width: 10, height: 3 }; let area = Rect {
x: 0,
y: 0,
width: 10,
height: 3,
};
let mut buf = Buffer::empty(area); let mut buf = Buffer::empty(area);
view.render(area, &mut buf); view.render(area, &mut buf);
// Collect all cell symbols into a flat string and verify the lines are present // Collect all cell symbols into a flat string and verify the lines are present
let content: String = buf let content: String = buf.content().iter().fold(String::new(), |mut acc, cell| {
.content() acc.push_str(cell.symbol());
.iter() acc
.fold(String::new(), |mut acc, cell| { acc.push_str(cell.symbol()); acc }); });
assert!(content.contains("line1")); assert!(content.contains("line1"));
assert!(content.contains("line2")); assert!(content.contains("line2"));
} }

View File

@@ -12,25 +12,25 @@ use crate::app_event_sender::AppEventSender;
use crate::user_approval_widget::ApprovalRequest; use crate::user_approval_widget::ApprovalRequest;
mod approval_modal_view; mod approval_modal_view;
mod mount_view;
mod shell_command_view;
mod inspect_env_view;
mod bottom_pane_view; mod bottom_pane_view;
mod chat_composer; mod chat_composer;
mod chat_composer_history; mod chat_composer_history;
mod command_popup; mod command_popup;
mod status_indicator_view;
mod config_reload_view; mod config_reload_view;
mod inspect_env_view;
mod mount_view;
mod shell_command_view;
mod status_indicator_view;
pub(crate) use chat_composer::ChatComposer; pub(crate) use chat_composer::ChatComposer;
pub(crate) use chat_composer::InputResult; pub(crate) use chat_composer::InputResult;
use approval_modal_view::ApprovalModalView; use approval_modal_view::ApprovalModalView;
use config_reload_view::ConfigReloadView;
use inspect_env_view::InspectEnvView;
use mount_view::{MountAddView, MountRemoveView}; use mount_view::{MountAddView, MountRemoveView};
use shell_command_view::ShellCommandView; use shell_command_view::ShellCommandView;
use inspect_env_view::InspectEnvView;
use status_indicator_view::StatusIndicatorView; use status_indicator_view::StatusIndicatorView;
use config_reload_view::ConfigReloadView;
/// Pane displayed in the lower half of the chat UI. /// Pane displayed in the lower half of the chat UI.
pub(crate) struct BottomPane<'a> { pub(crate) struct BottomPane<'a> {
@@ -266,9 +266,9 @@ impl WidgetRef for &BottomPane<'_> {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use std::sync::mpsc;
use crate::app_event::AppEvent; use crate::app_event::AppEvent;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::mpsc;
/// Construct a BottomPane with default parameters for testing. /// Construct a BottomPane with default parameters for testing.
fn make_pane() -> BottomPane<'static> { fn make_pane() -> BottomPane<'static> {
@@ -309,7 +309,12 @@ mod tests {
// Status indicator overlay remains active // Status indicator overlay remains active
assert!(pane.active_view.is_some()); assert!(pane.active_view.is_some());
// The overlay should be a StatusIndicatorView // The overlay should be a StatusIndicatorView
assert!(pane.active_view.as_mut().unwrap().should_hide_when_task_is_done()); assert!(
pane.active_view
.as_mut()
.unwrap()
.should_hide_when_task_is_done()
);
} }
#[test] #[test]
@@ -361,9 +366,14 @@ mod tests {
// Overlay remains active until task is marked done // Overlay remains active until task is marked done
assert!(pane.active_view.is_some()); assert!(pane.active_view.is_some());
// Should still be showing the status indicator overlay // Should still be showing the status indicator overlay
assert!(pane.active_view.as_mut().unwrap().should_hide_when_task_is_done()); assert!(
pane.active_view
.as_mut()
.unwrap()
.should_hide_when_task_is_done()
);
// The composer buffer should be cleared after submission // The composer buffer should be cleared after submission
let content = pane.composer.textarea.lines().join("\n"); let content = pane.composer.get_input_text();
assert_eq!(content, ""); assert_eq!(content, "");
} }
} }

View File

@@ -3,7 +3,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::prelude::Widget; use ratatui::prelude::Widget;
use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use tui_input::{backend::crossterm::EventHandler, Input}; use tui_input::{Input, backend::crossterm::EventHandler};
use super::BottomPane; use super::BottomPane;
use super::BottomPaneView; use super::BottomPaneView;
@@ -30,7 +30,12 @@ impl ShellCommandView {
impl<'a> BottomPaneView<'a> for ShellCommandView { impl<'a> BottomPaneView<'a> for ShellCommandView {
fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) { fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) {
// Exit shell prompt on Ctrl+M // Exit shell prompt on Ctrl+M
if let KeyEvent { code: KeyCode::Char('m'), modifiers: KeyModifiers::CONTROL, .. } = key_event { if let KeyEvent {
code: KeyCode::Char('m'),
modifiers: KeyModifiers::CONTROL,
..
} = key_event
{
self.done = true; self.done = true;
pane.request_redraw(); pane.request_redraw();
return; return;
@@ -91,7 +96,10 @@ mod tests {
composer_max_rows: 1, composer_max_rows: 1,
}); });
// Enter command 'a' // Enter command 'a'
view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE)); view.handle_key_event(
&mut pane,
KeyEvent::new(KeyCode::Char('a'), KeyModifiers::NONE),
);
view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); view.handle_key_event(&mut pane, KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Skip initial redraw event(s) // Skip initial redraw event(s)
let mut event; let mut event;

View File

@@ -42,7 +42,12 @@ impl<'a> BottomPaneView<'a> for StatusIndicatorView {
// Render the status overlay in a floating box immediately above the textarea // Render the status overlay in a floating box immediately above the textarea
let h = self.view.get_height(); let h = self.view.get_height();
let y = area.y.saturating_sub(h); let y = area.y.saturating_sub(h);
let rect = Rect { x: area.x, y, width: area.width, height: h }; let rect = Rect {
x: area.x,
y,
width: area.width,
height: h,
};
self.view.render_ref(rect, buf); self.view.render_ref(rect, buf);
} }
} }

View File

@@ -1,6 +1,9 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use codex_core::ContentItem;
use codex_core::ReasoningItemReasoningSummary;
use codex_core::ResponseItem;
use codex_core::codex_wrapper::init_codex; use codex_core::codex_wrapper::init_codex;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::AgentMessageEvent;
@@ -18,9 +21,6 @@ use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::Op; use codex_core::protocol::Op;
use codex_core::protocol::PatchApplyBeginEvent; use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::TaskCompleteEvent; use codex_core::protocol::TaskCompleteEvent;
use codex_core::ReasoningItemReasoningSummary;
use codex_core::ResponseItem;
use codex_core::ContentItem;
use crossterm::event::KeyEvent; use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer; use ratatui::buffer::Buffer;
use ratatui::layout::Constraint; use ratatui::layout::Constraint;
@@ -214,7 +214,8 @@ impl ChatWidget<'_> {
// Only show text portion in conversation history for now. // Only show text portion in conversation history for now.
if !text.is_empty() { if !text.is_empty() {
self.conversation_history.add_user_message(&self.config, text); self.conversation_history
.add_user_message(&self.config, text);
} }
self.conversation_history.scroll_to_bottom(); self.conversation_history.scroll_to_bottom();
} }
@@ -237,7 +238,8 @@ impl ChatWidget<'_> {
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join(""); .join("");
if role.eq_ignore_ascii_case("user") { if role.eq_ignore_ascii_case("user") {
self.conversation_history.add_user_message(&self.config, text); self.conversation_history
.add_user_message(&self.config, text);
} else { } else {
self.conversation_history self.conversation_history
.add_agent_message(&self.config, text); .add_agent_message(&self.config, text);
@@ -290,7 +292,9 @@ impl ChatWidget<'_> {
// record raw item for context-left calculation // record raw item for context-left calculation
self.history_items.push(ResponseItem::Message { self.history_items.push(ResponseItem::Message {
role: "assistant".to_string(), role: "assistant".to_string(),
content: vec![ContentItem::OutputText { text: message.clone() }], content: vec![ContentItem::OutputText {
text: message.clone(),
}],
}); });
self.request_redraw(); self.request_redraw();
} }
@@ -310,7 +314,8 @@ impl ChatWidget<'_> {
}) => { }) => {
self.bottom_pane.set_task_running(false); self.bottom_pane.set_task_running(false);
// update context-left after turn completes // update context-left after turn completes
let pct = calculate_context_percent_remaining(&self.history_items, &self.config.model); let pct =
calculate_context_percent_remaining(&self.history_items, &self.config.model);
self.bottom_pane.set_context_percent(pct); self.bottom_pane.set_context_percent(pct);
self.request_redraw(); self.request_redraw();
} }
@@ -318,7 +323,8 @@ impl ChatWidget<'_> {
self.conversation_history.add_error(message); self.conversation_history.add_error(message);
self.bottom_pane.set_task_running(false); self.bottom_pane.set_task_running(false);
// update context-left after error // update context-left after error
let pct = calculate_context_percent_remaining(&self.history_items, &self.config.model); let pct =
calculate_context_percent_remaining(&self.history_items, &self.config.model);
self.bottom_pane.set_context_percent(pct); self.bottom_pane.set_context_percent(pct);
} }
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent { EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
@@ -350,8 +356,11 @@ impl ChatWidget<'_> {
// prompt before they have seen *what* is being requested. // prompt before they have seen *what* is being requested.
// ------------------------------------------------------------------ // ------------------------------------------------------------------
self.conversation_history self.conversation_history.add_patch_event(
.add_patch_event(&self.config, PatchEventType::ApprovalRequest, changes); &self.config,
PatchEventType::ApprovalRequest,
changes,
);
self.conversation_history.scroll_to_bottom(); self.conversation_history.scroll_to_bottom();
@@ -380,8 +389,11 @@ impl ChatWidget<'_> {
}) => { }) => {
// Even when a patch is autoapproved we still display the // Even when a patch is autoapproved we still display the
// summary so the user can follow along. // summary so the user can follow along.
self.conversation_history self.conversation_history.add_patch_event(
.add_patch_event(&self.config, PatchEventType::ApplyBegin { auto_approved }, changes); &self.config,
PatchEventType::ApplyBegin { auto_approved },
changes,
);
if !auto_approved { if !auto_approved {
self.conversation_history.scroll_to_bottom(); self.conversation_history.scroll_to_bottom();
} }
@@ -494,27 +506,48 @@ impl ChatWidget<'_> {
self.next_shell_call_id += 1; self.next_shell_call_id += 1;
// Split command into arguments, fallback to raw string if parse fails // Split command into arguments, fallback to raw string if parse fails
let args = shlex::split(&cmd).unwrap_or_else(|| vec![cmd.clone()]); let args = shlex::split(&cmd).unwrap_or_else(|| vec![cmd.clone()]);
self.conversation_history.add_active_exec_command(call_id.clone(), args.clone()); self.conversation_history
.add_active_exec_command(call_id.clone(), args.clone());
let tx = self.app_event_tx.clone(); let tx = self.app_event_tx.clone();
// Spawn execution in background // Spawn execution in background
tokio::spawn(async move { tokio::spawn(async move {
let output = std::process::Command::new("sh").arg("-c").arg(&cmd).output(); let output = std::process::Command::new("sh")
.arg("-c")
.arg(&cmd)
.output();
match output { match output {
Ok(out) => { Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned(); let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned(); let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
let code = out.status.code().unwrap_or(-1); let code = out.status.code().unwrap_or(-1);
tx.send(AppEvent::ShellCommandResult { call_id, stdout, stderr, exit_code: code }); tx.send(AppEvent::ShellCommandResult {
call_id,
stdout,
stderr,
exit_code: code,
});
} }
Err(e) => { Err(e) => {
tx.send(AppEvent::ShellCommandResult { call_id, stdout: String::new(), stderr: e.to_string(), exit_code: -1 }); tx.send(AppEvent::ShellCommandResult {
call_id,
stdout: String::new(),
stderr: e.to_string(),
exit_code: -1,
});
} }
} }
}); });
} }
/// Handle completion of a shell command: display its result. /// Handle completion of a shell command: display its result.
pub fn handle_shell_command_result(&mut self, call_id: String, stdout: String, stderr: String, exit_code: i32) { pub fn handle_shell_command_result(
self.conversation_history.record_completed_exec_command(call_id, stdout, stderr, exit_code); &mut self,
call_id: String,
stdout: String,
stderr: String,
exit_code: i32,
) {
self.conversation_history
.record_completed_exec_command(call_id, stdout, stderr, exit_code);
} }
fn request_redraw(&mut self) { fn request_redraw(&mut self) {

View File

@@ -17,6 +17,10 @@ mod tests {
let old = "a\nb\nc\n"; let old = "a\nb\nc\n";
let new = "a\nx\nc\n"; let new = "a\nx\nc\n";
let diff = generate_diff(old, new); let diff = generate_diff(old, new);
assert!(diff.contains("-b\n+x\n"), "Unexpected diff output: {}", diff); assert!(
diff.contains("-b\n+x\n"),
"Unexpected diff output: {}",
diff
);
} }
} }

View File

@@ -1,8 +1,8 @@
//! Utilities for computing approximate token usage and remaining context percentage //! Utilities for computing approximate token usage and remaining context percentage
//! in the TUI, mirroring the JS heuristics in `calculateContextPercentRemaining`. //! in the TUI, mirroring the JS heuristics in `calculateContextPercentRemaining`.
use codex_core::ResponseItem;
use codex_core::ContentItem; use codex_core::ContentItem;
use codex_core::ResponseItem;
/// Roughly estimate number of model tokens represented by the given response items. /// Roughly estimate number of model tokens represented by the given response items.
/// Counts characters in text and function-call items, divides by 4 and rounds up. /// Counts characters in text and function-call items, divides by 4 and rounds up.
@@ -10,18 +10,21 @@ pub fn approximate_tokens_used(items: &[ResponseItem]) -> usize {
let mut char_count = 0; let mut char_count = 0;
for item in items { for item in items {
match item { match item {
ResponseItem::Message { role, content } if role.eq_ignore_ascii_case("user") ResponseItem::Message { role, content }
|| role.eq_ignore_ascii_case("assistant") => if role.eq_ignore_ascii_case("user") || role.eq_ignore_ascii_case("assistant") =>
{ {
for ci in content { for ci in content {
match ci { match ci {
ContentItem::InputText { text } ContentItem::InputText { text } | ContentItem::OutputText { text } => {
| ContentItem::OutputText { text } => char_count += text.len(), char_count += text.len()
}
_ => {} _ => {}
} }
} }
} }
ResponseItem::FunctionCall { name, arguments, .. } => { ResponseItem::FunctionCall {
name, arguments, ..
} => {
char_count += name.len(); char_count += name.len();
char_count += arguments.len(); char_count += arguments.len();
} }
@@ -54,10 +57,7 @@ pub fn max_tokens_for_model(model: &str) -> usize {
/// Compute the percentage of tokens remaining in context for a given model. /// Compute the percentage of tokens remaining in context for a given model.
/// Returns a floating-point percent (0.0100.0). /// Returns a floating-point percent (0.0100.0).
pub fn calculate_context_percent_remaining( pub fn calculate_context_percent_remaining(items: &[ResponseItem], model: &str) -> f64 {
items: &[ResponseItem],
model: &str,
) -> f64 {
let used = approximate_tokens_used(items); let used = approximate_tokens_used(items);
let max = max_tokens_for_model(model); let max = max_tokens_for_model(model);
let remaining = max.saturating_sub(used); let remaining = max.saturating_sub(used);

View File

@@ -6,11 +6,11 @@ use crate::text_formatting::format_and_truncate_tool_result;
use base64::Engine; use base64::Engine;
use codex_ansi_escape::ansi_escape_line; use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration; use codex_common::elapsed::format_duration;
use codex_core::WireApi;
use codex_core::config::Config; use codex_core::config::Config;
use codex_core::model_supports_reasoning_summaries; use codex_core::model_supports_reasoning_summaries;
use codex_core::protocol::FileChange; use codex_core::protocol::FileChange;
use codex_core::protocol::SessionConfiguredEvent; use codex_core::protocol::SessionConfiguredEvent;
use codex_core::WireApi;
use image::DynamicImage; use image::DynamicImage;
use image::GenericImageView; use image::GenericImageView;
use image::ImageReader; use image::ImageReader;
@@ -22,9 +22,9 @@ use ratatui::style::Modifier;
use ratatui::style::Style; use ratatui::style::Style;
use ratatui::text::Line as RtLine; use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan; use ratatui::text::Span as RtSpan;
use ratatui_image::picker::ProtocolType;
use ratatui_image::Image as TuiImage; use ratatui_image::Image as TuiImage;
use ratatui_image::Resize as ImgResize; use ratatui_image::Resize as ImgResize;
use ratatui_image::picker::ProtocolType;
use std::collections::HashMap; use std::collections::HashMap;
use std::io::Cursor; use std::io::Cursor;
use std::path::PathBuf; use std::path::PathBuf;
@@ -76,7 +76,8 @@ pub(crate) enum PatchEventType {
/// Represents an event to display in the conversation history. Returns its /// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a /// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list. /// scrollable list.
pub(crate) enum HistoryCell { /// Highlevel cells in the conversation history, rendered as text blocks.
pub enum HistoryCell {
/// Welcome message. /// Welcome message.
WelcomeMessage { view: TextBlock }, WelcomeMessage { view: TextBlock },
@@ -219,121 +220,130 @@ impl HistoryCell {
} }
} }
pub(crate) fn new_user_prompt(config: &Config, message: String) -> Self { /// Create a user prompt cell for testing or rendering user messages.
let body: Vec<RtLine<'static>> = message pub fn new_user_prompt(config: &Config, message: String) -> Self {
.lines() let body: Vec<RtLine<'static>> = message
.map(|l| RtLine::from(l.to_string())) .lines()
.collect(); .map(|l| RtLine::from(l.to_string()))
let label = RtSpan::styled( .collect();
"user".to_string(), let label = RtSpan::styled(
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD), "user".to_string(),
); Style::default()
// Render sender and content according to sender_break_line; insert message spacing if configured .fg(Color::Cyan)
let mut lines = if config.tui.sender_break_line { .add_modifier(Modifier::BOLD),
let mut l = Vec::new(); );
// label on its own line // Render sender and content according to sender_break_line; insert message spacing if configured
l.push(RtLine::from(vec![label.clone()])); let mut lines = if config.tui.sender_break_line {
// then message body lines let mut l = Vec::new();
l.extend(body.clone()); // label on its own line
l
} else {
// combine sender label and first line of body, indenting subsequent lines
let mut l = Vec::new();
if let Some(first) = body.get(0) {
let mut spans = vec![label.clone(), RtSpan::raw(" ".to_string())];
spans.extend(first.spans.clone());
l.push(RtLine::from(spans).style(first.style));
let indent = " ".to_string();
for ln in body.iter().skip(1) {
let text: String = ln.spans.iter().map(|s| s.content.clone()).collect();
l.push(RtLine::from(indent.clone() + &text));
}
} else {
l.push(RtLine::from(vec![label.clone()])); l.push(RtLine::from(vec![label.clone()]));
// then message body lines
l.extend(body.clone());
l
} else {
// combine sender label and first line of body, indenting subsequent lines
let mut l = Vec::new();
if let Some(first) = body.get(0) {
let mut spans = vec![label.clone(), RtSpan::raw(" ".to_string())];
spans.extend(first.spans.clone());
l.push(RtLine::from(spans).style(first.style));
let indent = " ".to_string();
for ln in body.iter().skip(1) {
let text: String = ln.spans.iter().map(|s| s.content.clone()).collect();
l.push(RtLine::from(indent.clone() + &text));
}
} else {
l.push(RtLine::from(vec![label.clone()]));
}
l
};
if config.tui.message_spacing {
lines.push(RtLine::from(""));
}
HistoryCell::UserPrompt {
view: TextBlock::new(lines),
} }
l
};
if config.tui.message_spacing {
lines.push(RtLine::from(""));
} }
HistoryCell::UserPrompt {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_agent_message(config: &Config, message: String) -> Self { /// Create an agent message cell for testing or rendering agent messages.
pub fn new_agent_message(config: &Config, message: String) -> Self {
let mut md_lines: Vec<RtLine<'static>> = Vec::new(); let mut md_lines: Vec<RtLine<'static>> = Vec::new();
append_markdown(&message, &mut md_lines, config); append_markdown(&message, &mut md_lines, config);
let label = RtSpan::styled( let label = RtSpan::styled(
"codex".to_string(), "codex".to_string(),
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD), Style::default()
); .fg(Color::Magenta)
// Render sender and content according to sender_break_line; insert message spacing if configured .add_modifier(Modifier::BOLD),
let mut lines = if config.tui.sender_break_line { );
let mut l = Vec::new(); // Render sender and content according to sender_break_line; insert message spacing if configured
l.push(RtLine::from(vec![label.clone()])); let mut lines = if config.tui.sender_break_line {
l.extend(md_lines.clone()); let mut l = Vec::new();
l
} else {
let mut l = Vec::new();
if let Some(first) = md_lines.get(0) {
let mut spans = vec![label.clone(), RtSpan::raw(" ".to_string())];
spans.extend(first.spans.clone());
l.push(RtLine::from(spans).style(first.style));
let indent = " ".to_string();
for ln in md_lines.iter().skip(1) {
let text: String = ln.spans.iter().map(|s| s.content.clone()).collect();
l.push(RtLine::from(indent.clone() + &text));
}
} else {
l.push(RtLine::from(vec![label.clone()])); l.push(RtLine::from(vec![label.clone()]));
l.extend(md_lines.clone());
l
} else {
let mut l = Vec::new();
if let Some(first) = md_lines.get(0) {
let mut spans = vec![label.clone(), RtSpan::raw(" ".to_string())];
spans.extend(first.spans.clone());
l.push(RtLine::from(spans).style(first.style));
let indent = " ".to_string();
for ln in md_lines.iter().skip(1) {
let text: String = ln.spans.iter().map(|s| s.content.clone()).collect();
l.push(RtLine::from(indent.clone() + &text));
}
} else {
l.push(RtLine::from(vec![label.clone()]));
}
l
};
if config.tui.message_spacing {
lines.push(RtLine::from(""));
}
HistoryCell::AgentMessage {
view: TextBlock::new(lines),
} }
l
};
if config.tui.message_spacing {
lines.push(RtLine::from(""));
} }
HistoryCell::AgentMessage {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_agent_reasoning(config: &Config, text: String) -> Self { /// Create an agent reasoning cell for testing or rendering agent reasoning.
pub fn new_agent_reasoning(config: &Config, text: String) -> Self {
let mut md_lines: Vec<RtLine<'static>> = Vec::new(); let mut md_lines: Vec<RtLine<'static>> = Vec::new();
append_markdown(&text, &mut md_lines, config); append_markdown(&text, &mut md_lines, config);
let label = RtSpan::styled( let label = RtSpan::styled(
"thinking".to_string(), "thinking".to_string(),
Style::default().fg(Color::Magenta).add_modifier(Modifier::ITALIC), Style::default()
); .fg(Color::Magenta)
// Render sender and content according to sender_break_line; insert message spacing if configured .add_modifier(Modifier::ITALIC),
let mut lines = if config.tui.sender_break_line { );
let mut l = Vec::new(); // Render sender and content according to sender_break_line; insert message spacing if configured
l.push(RtLine::from(vec![label.clone()])); let mut lines = if config.tui.sender_break_line {
l.extend(md_lines.clone()); let mut l = Vec::new();
l
} else {
let mut l = Vec::new();
if let Some(first) = md_lines.get(0) {
let mut spans = vec![label.clone(), RtSpan::raw(" ".to_string())];
spans.extend(first.spans.clone());
l.push(RtLine::from(spans).style(first.style));
let indent = " ".to_string();
for ln in md_lines.iter().skip(1) {
let text: String = ln.spans.iter().map(|s| s.content.clone()).collect();
l.push(RtLine::from(indent.clone() + &text));
}
} else {
l.push(RtLine::from(vec![label.clone()])); l.push(RtLine::from(vec![label.clone()]));
l.extend(md_lines.clone());
l
} else {
let mut l = Vec::new();
if let Some(first) = md_lines.get(0) {
let mut spans = vec![label.clone(), RtSpan::raw(" ".to_string())];
spans.extend(first.spans.clone());
l.push(RtLine::from(spans).style(first.style));
let indent = " ".to_string();
for ln in md_lines.iter().skip(1) {
let text: String = ln.spans.iter().map(|s| s.content.clone()).collect();
l.push(RtLine::from(indent.clone() + &text));
}
} else {
l.push(RtLine::from(vec![label.clone()]));
}
l
};
if config.tui.message_spacing {
lines.push(RtLine::from(""));
}
HistoryCell::AgentReasoning {
view: TextBlock::new(lines),
} }
l
};
if config.tui.message_spacing {
lines.push(RtLine::from(""));
} }
HistoryCell::AgentReasoning {
view: TextBlock::new(lines),
}
}
pub(crate) fn new_active_exec_command(call_id: String, command: Vec<String>) -> Self { pub(crate) fn new_active_exec_command(call_id: String, command: Vec<String>) -> Self {
let command_escaped = strip_bash_lc_and_escape(&command); let command_escaped = strip_bash_lc_and_escape(&command);
@@ -603,44 +613,65 @@ pub(crate) fn new_user_prompt(config: &Config, message: String) -> Self {
/// Create a new `PendingPatch` cell that lists the filelevel summary of /// Create a new `PendingPatch` cell that lists the filelevel summary of
/// a proposed patch. The summary lines should already be formatted (e.g. /// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs"). /// "A path/to/file.rs").
pub(crate) fn new_patch_event(config: &Config, event_type: PatchEventType, changes: HashMap<PathBuf, FileChange>) -> Self { pub(crate) fn new_patch_event(
// Handle applied patch immediately. config: &Config,
if let PatchEventType::ApplyBegin { auto_approved: false } = event_type { event_type: PatchEventType,
let lines = vec![RtLine::from("patch applied".magenta().bold())]; changes: HashMap<PathBuf, FileChange>,
return Self::PendingPatch { view: TextBlock::new(lines) }; ) -> Self {
} // Handle applied patch immediately.
let title = match event_type { if let PatchEventType::ApplyBegin {
PatchEventType::ApprovalRequest => "proposed patch", auto_approved: false,
PatchEventType::ApplyBegin { auto_approved: true } => "applying patch", } = event_type
_ => unreachable!(), {
}; let lines = vec![RtLine::from("patch applied".magenta().bold())];
let summary = create_diff_summary(changes); return Self::PendingPatch {
let body: Vec<RtLine<'static>> = summary.into_iter().map(|line| { view: TextBlock::new(lines),
if line.starts_with('+') {
RtLine::from(line).green()
} else if line.starts_with('-') {
RtLine::from(line).red()
} else if let Some(idx) = line.find(' ') {
let kind = line[..idx].to_string();
let rest = line[idx + 1..].to_string();
let style_for = |fg| Style::default().fg(fg).add_modifier(Modifier::BOLD);
let kind_span = match kind.as_str() {
"A" => RtSpan::styled(kind.clone(), style_for(Color::Green)),
"D" => RtSpan::styled(kind.clone(), style_for(Color::Red)),
"M" => RtSpan::styled(kind.clone(), style_for(Color::Yellow)),
"R" | "C" => RtSpan::styled(kind.clone(), style_for(Color::Cyan)),
_ => RtSpan::raw(kind.clone()),
}; };
RtLine::from(vec![kind_span, RtSpan::raw(" "), RtSpan::raw(rest)])
} else {
RtLine::from(line)
} }
}).collect(); let title = match event_type {
let label = RtSpan::styled(title.to_string(), Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD)); PatchEventType::ApprovalRequest => "proposed patch",
let mut lines = render_header_body(config, label, body); PatchEventType::ApplyBegin {
lines.push(RtLine::from("")); auto_approved: true,
HistoryCell::PendingPatch { view: TextBlock::new(lines) } } => "applying patch",
} _ => unreachable!(),
};
let summary = create_diff_summary(changes);
let body: Vec<RtLine<'static>> = summary
.into_iter()
.map(|line| {
if line.starts_with('+') {
RtLine::from(line).green()
} else if line.starts_with('-') {
RtLine::from(line).red()
} else if let Some(idx) = line.find(' ') {
let kind = line[..idx].to_string();
let rest = line[idx + 1..].to_string();
let style_for = |fg| Style::default().fg(fg).add_modifier(Modifier::BOLD);
let kind_span = match kind.as_str() {
"A" => RtSpan::styled(kind.clone(), style_for(Color::Green)),
"D" => RtSpan::styled(kind.clone(), style_for(Color::Red)),
"M" => RtSpan::styled(kind.clone(), style_for(Color::Yellow)),
"R" | "C" => RtSpan::styled(kind.clone(), style_for(Color::Cyan)),
_ => RtSpan::raw(kind.clone()),
};
RtLine::from(vec![kind_span, RtSpan::raw(" "), RtSpan::raw(rest)])
} else {
RtLine::from(line)
}
})
.collect();
let label = RtSpan::styled(
title.to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
let mut lines = render_header_body(config, label, body);
lines.push(RtLine::from(""));
HistoryCell::PendingPatch {
view: TextBlock::new(lines),
}
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -764,7 +795,7 @@ fn create_diff_summary(changes: HashMap<PathBuf, FileChange>) -> Vec<String> {
/// available space. Keeping the resized copy around saves a costly rescale /// available space. Keeping the resized copy around saves a costly rescale
/// between the back-to-back `height()` and `render_window()` calls that the /// between the back-to-back `height()` and `render_window()` calls that the
/// scroll-view performs while laying out the UI. /// scroll-view performs while laying out the UI.
pub(crate) struct ImageRenderCache { pub struct ImageRenderCache {
/// Width in *terminal cells* the cached image was generated for. /// Width in *terminal cells* the cached image was generated for.
width_cells: u16, width_cells: u16,
/// Height in *terminal rows* that the conversation cell must occupy so /// Height in *terminal rows* that the conversation cell must occupy so

View File

@@ -31,24 +31,24 @@ mod cell_widget;
mod chatwidget; mod chatwidget;
mod citation_regex; mod citation_regex;
mod cli; mod cli;
mod config_reload;
mod confirm_ctrl_d;
pub mod context;
mod conversation_history_widget; mod conversation_history_widget;
mod exec_command; mod exec_command;
mod git_warning_screen; mod git_warning_screen;
mod history_cell; pub mod history_cell;
mod log_layer; mod log_layer;
mod login_screen; mod login_screen;
mod markdown; mod markdown;
mod mouse_capture; mod mouse_capture;
mod scroll_event_helper; mod scroll_event_helper;
mod slash_command; mod slash_command;
mod confirm_ctrl_d;
mod status_indicator_widget; mod status_indicator_widget;
pub mod context; pub mod text_block;
mod text_block;
mod text_formatting; mod text_formatting;
mod tui; mod tui;
mod user_approval_widget; mod user_approval_widget;
mod config_reload;
pub use cli::Cli; pub use cli::Cli;
@@ -222,16 +222,19 @@ fn run_ratatui_app(
let app_event_tx = app.event_sender(); let app_event_tx = app.event_sender();
let config_path = config.codex_home.join("config.toml"); let config_path = config.codex_home.join("config.toml");
std::thread::spawn(move || { std::thread::spawn(move || {
use notify::{Watcher, RecursiveMode, RecommendedWatcher, EventKind}; use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::sync::mpsc::channel; use std::sync::mpsc::channel;
use std::time::Duration; use std::time::Duration;
let (tx, rx) = channel(); let (tx, rx) = channel();
let mut watcher: RecommendedWatcher = let mut watcher: RecommendedWatcher = Watcher::new(tx, notify::Config::default())
Watcher::new(tx, notify::Config::default()).unwrap_or_else(|e| { .unwrap_or_else(|e| {
tracing::error!("config watcher failed: {e}"); tracing::error!("config watcher failed: {e}");
std::process::exit(1); std::process::exit(1);
}); });
if watcher.watch(&config_path, RecursiveMode::NonRecursive).is_err() { if watcher
.watch(&config_path, RecursiveMode::NonRecursive)
.is_err()
{
tracing::error!("Failed to watch config.toml"); tracing::error!("Failed to watch config.toml");
return; return;
} }
@@ -244,9 +247,8 @@ fn run_ratatui_app(
if new != last { if new != last {
let diff = crate::config_reload::generate_diff(&last, &new); let diff = crate::config_reload::generate_diff(&last, &new);
last = new.clone(); last = new.clone();
app_event_tx.send( app_event_tx
crate::app_event::AppEvent::ConfigReloadRequest(diff) .send(crate::app_event::AppEvent::ConfigReloadRequest(diff));
);
} }
} }
} }

View File

@@ -13,7 +13,12 @@ pub(crate) fn append_markdown(
config: &Config, config: &Config,
) { ) {
let mut new_lines = Vec::new(); let mut new_lines = Vec::new();
append_markdown_with_opener_and_cwd(markdown_source, &mut new_lines, config.file_opener, &config.cwd); append_markdown_with_opener_and_cwd(
markdown_source,
&mut new_lines,
config.file_opener,
&config.cwd,
);
if config.tui.markdown_compact { if config.tui.markdown_compact {
for line in collapse_heading_blank_lines(new_lines) { for line in collapse_heading_blank_lines(new_lines) {
lines.push(line); lines.push(line);
@@ -66,7 +71,11 @@ fn collapse_heading_blank_lines(lines: Vec<Line<'static>>) -> Vec<Line<'static>>
let mut result = Vec::with_capacity(lines.len()); let mut result = Vec::with_capacity(lines.len());
let mut prev_was_heading = false; let mut prev_was_heading = false;
for line in lines { for line in lines {
let content = line.spans.iter().map(|s| s.content.clone()).collect::<String>(); let content = line
.spans
.iter()
.map(|s| s.content.clone())
.collect::<String>();
if prev_was_heading && content.trim().is_empty() { if prev_was_heading && content.trim().is_empty() {
continue; continue;
} }

View File

@@ -32,13 +32,15 @@ impl SlashCommand {
pub fn description(self) -> &'static str { pub fn description(self) -> &'static str {
match self { match self {
SlashCommand::New => "Start a new chat.", SlashCommand::New => "Start a new chat.",
SlashCommand::ToggleMouseMode => SlashCommand::ToggleMouseMode => {
"Toggle mouse mode (enable for scrolling, disable for text selection)", "Toggle mouse mode (enable for scrolling, disable for text selection)"
SlashCommand::EditPrompt => }
"Open external editor to edit the current prompt.", SlashCommand::EditPrompt => "Open external editor to edit the current prompt.",
SlashCommand::MountAdd => "Add a mount: host path → container path.", SlashCommand::MountAdd => "Add a mount: host path → container path.",
SlashCommand::MountRemove => "Remove a mount by container path.", SlashCommand::MountRemove => "Remove a mount by container path.",
SlashCommand::InspectEnv => "Inspect sandbox and container environment (mounts, permissions, network)", SlashCommand::InspectEnv => {
"Inspect sandbox and container environment (mounts, permissions, network)"
}
SlashCommand::Shell => "Run a shell command in the container.", SlashCommand::Shell => "Run a shell command in the container.",
SlashCommand::Quit => "Exit the application.", SlashCommand::Quit => "Exit the application.",
} }

View File

@@ -99,8 +99,7 @@ impl WidgetRef for StatusIndicatorWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) { fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Flush status text to the top of the input border, without extra padding // Flush status text to the top of the input border, without extra padding
// Remove surrounding border for a cleaner in-line status display // Remove surrounding border for a cleaner in-line status display
let block = Block::default() let block = Block::default().padding(Padding::new(0, 0, 0, 0));
.padding(Padding::new(0, 0, 0, 0));
// Animated 3dot pattern inside brackets. The *active* dot is bold // Animated 3dot pattern inside brackets. The *active* dot is bold
// white, the others are dim. // white, the others are dim.
const DOT_COUNT: usize = 3; const DOT_COUNT: usize = 3;

View File

@@ -3,13 +3,16 @@ use ratatui::prelude::*;
/// A simple widget that just displays a list of `Line`s via a `Paragraph`. /// A simple widget that just displays a list of `Line`s via a `Paragraph`.
/// This is the default rendering backend for most `HistoryCell` variants. /// This is the default rendering backend for most `HistoryCell` variants.
/// A simple widget that displays a list of lines via a paragraph.
#[derive(Clone)] #[derive(Clone)]
pub(crate) struct TextBlock { pub struct TextBlock {
pub(crate) lines: Vec<Line<'static>>, /// The content lines to render.
pub lines: Vec<Line<'static>>,
} }
impl TextBlock { impl TextBlock {
pub(crate) fn new(lines: Vec<Line<'static>>) -> Self { /// Create a new text block from preformatted lines.
pub fn new(lines: Vec<Line<'static>>) -> Self {
Self { lines } Self { lines }
} }
} }

View File

@@ -110,7 +110,10 @@ fn truncate_middle(text: &str, max_len: usize) -> String {
/// Build the dynamic session-scoped approval label for the given command string. /// Build the dynamic session-scoped approval label for the given command string.
fn session_scoped_label(cmd: &str, max_len: usize) -> String { fn session_scoped_label(cmd: &str, max_len: usize) -> String {
let snippet = truncate_middle(cmd, max_len); let snippet = truncate_middle(cmd, max_len);
format!("Yes, always allow running `{}` for this session (a)", snippet) format!(
"Yes, always allow running `{}` for this session (a)",
snippet
)
} }
/// Internal mode the widget is in mirrors the TypeScript component. /// Internal mode the widget is in mirrors the TypeScript component.
@@ -385,7 +388,9 @@ impl WidgetRef for &UserApprovalWidget<'_> {
.enumerate() .enumerate()
.map(|(idx, opt)| { .map(|(idx, opt)| {
let label = if idx == 1 { let label = if idx == 1 {
dynamic_label.clone().unwrap_or_else(|| opt.label.to_string()) dynamic_label
.clone()
.unwrap_or_else(|| opt.label.to_string())
} else { } else {
opt.label.to_string() opt.label.to_string()
}; };
@@ -423,10 +428,10 @@ impl WidgetRef for &UserApprovalWidget<'_> {
mod tests { mod tests {
use super::*; use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::mpsc; use ratatui::buffer::Buffer;
use ratatui::buffer::{Buffer, Cell};
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::{Color, Style}; use ratatui::style::Color;
use std::sync::mpsc;
#[test] #[test]
fn esc_in_input_mode_cancels_input_and_preserves_value() { fn esc_in_input_mode_cancels_input_and_preserves_value() {
@@ -473,7 +478,10 @@ mod tests {
#[test] #[test]
fn test_session_scoped_label_embeds_snippet() { fn test_session_scoped_label_embeds_snippet() {
let label = session_scoped_label("say hello", 50); let label = session_scoped_label("say hello", 50);
assert_eq!(label, "Yes, always allow running `say hello` for this session (a)"); assert_eq!(
label,
"Yes, always allow running `say hello` for this session (a)"
);
let long_cmd = "x".repeat(100); let long_cmd = "x".repeat(100);
let truncated = session_scoped_label(&long_cmd, 10); let truncated = session_scoped_label(&long_cmd, 10);
assert!(truncated.starts_with("Yes, always allow running `")); assert!(truncated.starts_with("Yes, always allow running `"));
@@ -515,8 +523,20 @@ mod tests {
for row in area.y..area.y + area.height { for row in area.y..area.y + area.height {
for col in area.x..area.x + area.width { for col in area.x..area.x + area.width {
let cell = buf.cell((col, row)).unwrap(); let cell = buf.cell((col, row)).unwrap();
assert_ne!(cell.bg, Color::Reset, "Found transparent cell at ({}, {})", col, row); assert_ne!(
assert_ne!(cell.bg, Color::Red, "Found unfilled cell at ({}, {})", col, row); cell.bg,
Color::Reset,
"Found transparent cell at ({}, {})",
col,
row
);
assert_ne!(
cell.bg,
Color::Red,
"Found unfilled cell at ({}, {})",
col,
row
);
} }
} }
} }

View File

@@ -1,19 +1,25 @@
use codex_core::{ContentItem, ResponseItem}; use codex_core::{ContentItem, ResponseItem};
use codex_tui::context::{approximate_tokens_used, calculate_context_percent_remaining, max_tokens_for_model}; use codex_tui::context::{
approximate_tokens_used, calculate_context_percent_remaining, max_tokens_for_model,
};
#[test] #[test]
fn test_approximate_tokens_used_texts() { fn test_approximate_tokens_used_texts() {
// 4 chars -> 1 token // 4 chars -> 1 token
let items = vec![ResponseItem::Message { let items = vec![ResponseItem::Message {
role: "user".into(), role: "user".into(),
content: vec![ContentItem::InputText { text: "abcd".into() }], content: vec![ContentItem::InputText {
text: "abcd".into(),
}],
}]; }];
assert_eq!(approximate_tokens_used(&items), 1); assert_eq!(approximate_tokens_used(&items), 1);
// 7 chars -> 2 tokens // 7 chars -> 2 tokens
let items = vec![ResponseItem::Message { let items = vec![ResponseItem::Message {
role: "assistant".into(), role: "assistant".into(),
content: vec![ContentItem::OutputText { text: "example".into() }], content: vec![ContentItem::OutputText {
text: "example".into(),
}],
}]; }];
assert_eq!(approximate_tokens_used(&items), 2); assert_eq!(approximate_tokens_used(&items), 2);
} }

View File

@@ -1,6 +1,6 @@
use tempfile::TempDir; use codex_core::config::{Config, ConfigOverrides, ConfigToml};
use codex_core::config::{Config, ConfigToml, ConfigOverrides};
use codex_tui::history_cell::HistoryCell; use codex_tui::history_cell::HistoryCell;
use tempfile::TempDir;
/// Extract plain string content of each line for comparison. /// Extract plain string content of each line for comparison.
fn lines_from_userprompt(view: &codex_tui::text_block::TextBlock) -> Vec<String> { fn lines_from_userprompt(view: &codex_tui::text_block::TextBlock) -> Vec<String> {
@@ -42,9 +42,11 @@ fn test_user_message_layout_combinations() {
if message_spacing { if message_spacing {
expected.push(String::new()); expected.push(String::new());
} }
assert_eq!(got, expected, assert_eq!(
got, expected,
"Layout mismatch for sender_break_line={}, message_spacing={}", "Layout mismatch for sender_break_line={}, message_spacing={}",
sender_break, message_spacing); sender_break, message_spacing
);
} }
} }
} }
@@ -81,9 +83,11 @@ fn test_agent_message_layout_combinations() {
if message_spacing { if message_spacing {
expected.push(String::new()); expected.push(String::new());
} }
assert_eq!(got, expected, assert_eq!(
got, expected,
"Agent layout mismatch for sender_break_line={}, message_spacing={}", "Agent layout mismatch for sender_break_line={}, message_spacing={}",
sender_break, message_spacing); sender_break, message_spacing
);
} }
} }
} }
@@ -120,9 +124,11 @@ fn test_agent_reasoning_layout_combinations() {
if message_spacing { if message_spacing {
expected.push(String::new()); expected.push(String::new());
} }
assert_eq!(got, expected, assert_eq!(
got, expected,
"Reasoning layout mismatch for sender_break_line={}, message_spacing={}", "Reasoning layout mismatch for sender_break_line={}, message_spacing={}",
sender_break, message_spacing); sender_break, message_spacing
);
} }
} }
} }