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]
files: ^agentydragon/tasks/.*
- id: cargo-build
name: Check Rust workspace builds
entry: bash -lc 'cd codex-rs && RUSTFLAGS="-D warnings" cargo build --workspace --locked'
name: Check Rust workspace and linux-sandbox compile
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
pass_filenames: false
require_serial: true

View File

@@ -4,14 +4,14 @@ use codex_cli::SeatbeltCommand;
use codex_cli::login::run_login_with_chatgpt;
use codex_cli::proto;
use codex_common::CliConfigOverrides;
use codex_core::config::find_codex_home;
use codex_exec::Cli as ExecCli;
use codex_tui::Cli as TuiCli;
use serde::de::Error as SerdeError;
use std::io::ErrorKind;
use std::path::PathBuf;
use std::{env, fs, process};
use std::io::ErrorKind;
use toml::{self, value::Table, Value};
use serde::de::Error as SerdeError;
use codex_core::config::find_codex_home;
use toml::{self, Value, value::Table};
use uuid::Uuid;
use crate::proto::ProtoCli;
@@ -42,7 +42,8 @@ struct MultitoolCli {
fn parse_toml_value(raw: &str) -> Result<Value, toml::de::Error> {
let wrapped = format!("_x_ = {raw}");
let table: Table = toml::from_str(&wrapped)?;
table.get("_x_")
table
.get("_x_")
.cloned()
.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.
let editor = env::var_os("EDITOR").unwrap_or_else(|| "vi".into());
let status = process::Command::new(editor)
.arg(&config_path)
.status()?;
let status = process::Command::new(editor).arg(&config_path).status()?;
if !status.success() {
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 {
toml::Value::Table(tbl) => {
current = tbl.entry((*part).to_string())
current = tbl
.entry((*part).to_string())
.or_insert_with(|| toml::Value::Table(Table::new()));
}
_ => {
*current = toml::Value::Table(Table::new());
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()));
}
}
@@ -268,7 +269,9 @@ mod tests {
#[test]
fn config_subcommands_help() {
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();
cfg.write_long_help(&mut 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.
#[cfg(test)]
mod cli_config {
use std::process::Command;
use std::fs;
use std::process::Command;
use tempfile;
use toml;

View File

@@ -66,7 +66,11 @@ pub(crate) async fn stream_chat_completions(
}
messages.push(json!({"role": role, "content": text}));
}
ResponseItem::FunctionCall { name, arguments, call_id } => {
ResponseItem::FunctionCall {
name,
arguments,
call_id,
} => {
// Mark tool invocation in-flight
pending_call = Some(call_id.clone());
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
if let Some(call_id) = id {
pending_call = Some(call_id.clone());

View File

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

View File

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

View File

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

View File

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

View File

@@ -129,7 +129,9 @@ fn default_composer_max_rows() -> usize {
/// Default editor: `$VISUAL`, then `$EDITOR`, falling back to `nvim`.
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.

View File

@@ -27,7 +27,9 @@ mod model_provider_info;
pub use model_provider_info::ModelProviderInfo;
pub use model_provider_info::WireApi;
mod models;
pub use models::{ContentItem, ReasoningItemReasoningSummary, ResponseItem, FunctionCallOutputPayload};
pub use models::{
ContentItem, FunctionCallOutputPayload, ReasoningItemReasoningSummary, ResponseItem,
};
pub mod openai_api_key;
mod openai_tools;
mod project_doc;
@@ -37,4 +39,4 @@ mod safety;
mod user_notification;
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 {
/// Grant disk-write permission for the specified folder.
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.

View File

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

View File

@@ -1,8 +1,8 @@
use serde_json::{json, Value};
use codex_core::{Prompt, ResponseItem, ContentItem, FunctionCallOutputPayload};
use codex_core::{ContentItem, FunctionCallOutputPayload, ResponseItem};
use serde_json::{Value, json};
/// 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 pending = None::<String>;
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}));
}
ResponseItem::FunctionCall { name, arguments, call_id } => {
ResponseItem::FunctionCall {
name,
arguments,
call_id,
} => {
pending = Some(call_id.clone());
messages.push(json!({
"role": "assistant", "content": null,
@@ -38,7 +42,9 @@ fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> {
}));
}
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) {
pending = None;
for m in buf_user.drain(..) {
@@ -52,7 +58,8 @@ fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> {
// cancellation: no output arrived
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(..) {
messages.push(m);
}
@@ -63,36 +70,82 @@ fn build_messages(input: Vec<ResponseItem>, model: &str) -> Vec<Value> {
#[test]
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");
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]
fn buffer_and_flush_on_output() {
let call_id = "c1".to_string();
let input = vec![
ResponseItem::FunctionCall { name: "f".into(), 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 } },
ResponseItem::FunctionCall {
name: "f".into(),
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");
// order: system, functioncall, tool output, then buffered user
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]
fn buffer_and_cancel() {
let call_id = "c2".to_string();
let input = vec![
ResponseItem::FunctionCall { name: "f".into(), arguments: "{}".into(), call_id: call_id.clone() },
ResponseItem::Message { role: "user".into(), content: vec![ContentItem::InputText { text: "oops".into() }] },
ResponseItem::FunctionCall {
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");
// expect system, functioncall, fake cancel, then user
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
assert_eq!(msgs[2]["content"], json!("Tool cancelled"));
}

View File

@@ -5,8 +5,6 @@ use std::ffi::CString;
use libc;
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)]
pub struct LandlockCommand {
@@ -26,48 +24,11 @@ pub fn run_main() -> ! {
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() {
Ok(cwd) => cwd,
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) {
panic!("error running landlock: {e:?}");

View File

@@ -1,7 +1,7 @@
use crate::app_event::AppEvent;
use crate::confirm_ctrl_d::ConfirmCtrlD;
use crate::app_event_sender::AppEventSender;
use crate::chatwidget::ChatWidget;
use crate::confirm_ctrl_d::ConfirmCtrlD;
use crate::git_warning_screen::GitWarningOutcome;
use crate::git_warning_screen::GitWarningScreen;
use crate::login_screen::LoginScreen;
@@ -69,14 +69,18 @@ struct ChatWidgetArgs {
}
/// 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 container = None;
let mut mode = "rw".to_string();
for token in raw.split_whitespace() {
let mut parts = token.splitn(2, '=');
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 {
"host" => host = 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() {
let mut parts = token.splitn(2, '=');
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" {
container = Some(std::path::PathBuf::from(value));
} else {
@@ -347,15 +353,19 @@ impl<'a> App<'a> {
let _ = child.wait();
}
Err(err) => {
let _ = tx.send(AppEvent::LatestLog(
format!("Failed to spawn inspect-env: {err}")
));
let _ = tx.send(AppEvent::LatestLog(format!(
"Failed to spawn inspect-env: {err}"
)));
}
}
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) {
tracing::error!("mount-add failed: {err}");
}
@@ -405,22 +415,22 @@ impl<'a> App<'a> {
}
}
}
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
..
} => {
// Handle Ctrl+D exit confirmation when enabled.
let now = Instant::now();
if self.confirm_ctrl_d.handle(now) {
break;
KeyEvent {
code: KeyCode::Char('d'),
modifiers: crossterm::event::KeyModifiers::CONTROL,
..
} => {
// Handle Ctrl+D exit confirmation when enabled.
let now = Instant::now();
if self.confirm_ctrl_d.handle(now) {
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);
}
@@ -482,7 +492,9 @@ impl<'a> App<'a> {
if let AppState::Chat { widget } = &mut self.app_state {
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 => {
if let AppState::Chat { widget } = &mut self.app_state {
@@ -496,8 +508,13 @@ impl<'a> App<'a> {
widget.handle_shell_command(cmd);
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 {
widget.handle_shell_command_result(call_id, stdout, stderr, exit_code);
self.app_event_tx.send(AppEvent::Redraw);

View File

@@ -63,3 +63,54 @@ pub(crate) enum AppEvent {
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)
}
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() {
// Inline DSL for mount-add/remove with args or dispatch other commands.
let first_line = self
@@ -181,7 +186,10 @@ impl ChatComposer<'_> {
.first()
.map(|s| s.as_str())
.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 _cmd_token = parts.next().unwrap_or("");
let args = parts.next().unwrap_or("").trim_start();
@@ -191,7 +199,9 @@ impl ChatComposer<'_> {
self.command_popup = None;
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 {
AppEvent::InlineMountAdd(args.to_string())
} else {
@@ -261,15 +271,28 @@ impl ChatComposer<'_> {
(InputResult::Submitted(text), true)
}
}
Input { key: Key::Enter, .. }
| Input { key: Key::Char('j'), ctrl: true, alt: false, shift: false } => {
Input {
key: Key::Enter, ..
}
| Input {
key: Key::Char('j'),
ctrl: true,
alt: false,
shift: false,
} => {
self.textarea.insert_newline();
(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
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)
}
input => self.handle_input_basic(input),
@@ -302,7 +325,9 @@ impl ChatComposer<'_> {
}
let path = tmp.path();
// 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
if let Err(e) = Command::new(editor).arg(path).status() {
tracing::error!("failed to launch editor: {e}");
@@ -381,10 +406,8 @@ impl ChatComposer<'_> {
let bs = if self.shell_mode {
BlockState {
right_title: Line::from(
"Shell mode Enter to run | Ctrl+M to exit shell mode",
)
.alignment(Alignment::Right),
right_title: Line::from("Shell mode Enter to run | Ctrl+M to exit shell mode")
.alignment(Alignment::Right),
border_style: Style::default().fg(Color::Red),
}
} else if has_focus {
@@ -451,7 +474,12 @@ impl WidgetRef for &ChatComposer<'_> {
} else {
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 ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::{Block, Borders, BorderType, Paragraph};
use ratatui::prelude::Widget;
use ratatui::widgets::{Block, BorderType, Borders, Paragraph};
use super::{BottomPane, BottomPaneView};
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use super::{BottomPane, BottomPaneView};
/// BottomPane view displaying the diff and prompting to apply or ignore.
pub(crate) struct ConfigReloadView {
@@ -18,7 +18,11 @@ pub(crate) struct ConfigReloadView {
impl ConfigReloadView {
/// Create a new view with the unified diff of config changes.
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)
.border_type(BorderType::Rounded)
.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 {

View File

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

View File

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

View File

@@ -3,7 +3,7 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
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::BottomPaneView;
@@ -30,7 +30,12 @@ impl ShellCommandView {
impl<'a> BottomPaneView<'a> for ShellCommandView {
fn handle_key_event(&mut self, pane: &mut BottomPane<'a>, key_event: KeyEvent) {
// 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;
pane.request_redraw();
return;
@@ -91,7 +96,10 @@ mod tests {
composer_max_rows: 1,
});
// 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));
// Skip initial redraw event(s)
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
let h = self.view.get_height();
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);
}
}

View File

@@ -1,6 +1,9 @@
use std::path::PathBuf;
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::config::Config;
use codex_core::protocol::AgentMessageEvent;
@@ -18,9 +21,6 @@ use codex_core::protocol::McpToolCallEndEvent;
use codex_core::protocol::Op;
use codex_core::protocol::PatchApplyBeginEvent;
use codex_core::protocol::TaskCompleteEvent;
use codex_core::ReasoningItemReasoningSummary;
use codex_core::ResponseItem;
use codex_core::ContentItem;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
@@ -214,7 +214,8 @@ impl ChatWidget<'_> {
// Only show text portion in conversation history for now.
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();
}
@@ -237,7 +238,8 @@ impl ChatWidget<'_> {
.collect::<Vec<_>>()
.join("");
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 {
self.conversation_history
.add_agent_message(&self.config, text);
@@ -290,7 +292,9 @@ impl ChatWidget<'_> {
// record raw item for context-left calculation
self.history_items.push(ResponseItem::Message {
role: "assistant".to_string(),
content: vec![ContentItem::OutputText { text: message.clone() }],
content: vec![ContentItem::OutputText {
text: message.clone(),
}],
});
self.request_redraw();
}
@@ -310,7 +314,8 @@ impl ChatWidget<'_> {
}) => {
self.bottom_pane.set_task_running(false);
// 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.request_redraw();
}
@@ -318,7 +323,8 @@ impl ChatWidget<'_> {
self.conversation_history.add_error(message);
self.bottom_pane.set_task_running(false);
// 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);
}
EventMsg::ExecApprovalRequest(ExecApprovalRequestEvent {
@@ -350,8 +356,11 @@ impl ChatWidget<'_> {
// prompt before they have seen *what* is being requested.
// ------------------------------------------------------------------
self.conversation_history
.add_patch_event(&self.config, PatchEventType::ApprovalRequest, changes);
self.conversation_history.add_patch_event(
&self.config,
PatchEventType::ApprovalRequest,
changes,
);
self.conversation_history.scroll_to_bottom();
@@ -380,8 +389,11 @@ impl ChatWidget<'_> {
}) => {
// Even when a patch is autoapproved we still display the
// summary so the user can follow along.
self.conversation_history
.add_patch_event(&self.config, PatchEventType::ApplyBegin { auto_approved }, changes);
self.conversation_history.add_patch_event(
&self.config,
PatchEventType::ApplyBegin { auto_approved },
changes,
);
if !auto_approved {
self.conversation_history.scroll_to_bottom();
}
@@ -494,27 +506,48 @@ impl ChatWidget<'_> {
self.next_shell_call_id += 1;
// Split command into arguments, fallback to raw string if parse fails
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();
// Spawn execution in background
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 {
Ok(out) => {
let stdout = String::from_utf8_lossy(&out.stdout).into_owned();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
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) => {
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.
pub fn handle_shell_command_result(&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);
pub fn handle_shell_command_result(
&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) {

View File

@@ -17,6 +17,10 @@ mod tests {
let old = "a\nb\nc\n";
let new = "a\nx\nc\n";
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
//! in the TUI, mirroring the JS heuristics in `calculateContextPercentRemaining`.
use codex_core::ResponseItem;
use codex_core::ContentItem;
use codex_core::ResponseItem;
/// 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.
@@ -10,18 +10,21 @@ pub fn approximate_tokens_used(items: &[ResponseItem]) -> usize {
let mut char_count = 0;
for item in items {
match item {
ResponseItem::Message { role, content } if role.eq_ignore_ascii_case("user")
|| role.eq_ignore_ascii_case("assistant") =>
ResponseItem::Message { role, content }
if role.eq_ignore_ascii_case("user") || role.eq_ignore_ascii_case("assistant") =>
{
for ci in content {
match ci {
ContentItem::InputText { text }
| ContentItem::OutputText { text } => char_count += text.len(),
ContentItem::InputText { text } | ContentItem::OutputText { text } => {
char_count += text.len()
}
_ => {}
}
}
}
ResponseItem::FunctionCall { name, arguments, .. } => {
ResponseItem::FunctionCall {
name, arguments, ..
} => {
char_count += name.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.
/// Returns a floating-point percent (0.0100.0).
pub fn calculate_context_percent_remaining(
items: &[ResponseItem],
model: &str,
) -> f64 {
pub fn calculate_context_percent_remaining(items: &[ResponseItem], model: &str) -> f64 {
let used = approximate_tokens_used(items);
let max = max_tokens_for_model(model);
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 codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration;
use codex_core::WireApi;
use codex_core::config::Config;
use codex_core::model_supports_reasoning_summaries;
use codex_core::protocol::FileChange;
use codex_core::protocol::SessionConfiguredEvent;
use codex_core::WireApi;
use image::DynamicImage;
use image::GenericImageView;
use image::ImageReader;
@@ -22,9 +22,9 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use ratatui_image::picker::ProtocolType;
use ratatui_image::Image as TuiImage;
use ratatui_image::Resize as ImgResize;
use ratatui_image::picker::ProtocolType;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::PathBuf;
@@ -76,7 +76,8 @@ pub(crate) enum PatchEventType {
/// 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 {
/// Highlevel cells in the conversation history, rendered as text blocks.
pub enum HistoryCell {
/// Welcome message.
WelcomeMessage { view: TextBlock },
@@ -219,121 +220,130 @@ impl HistoryCell {
}
}
pub(crate) fn new_user_prompt(config: &Config, message: String) -> Self {
let body: Vec<RtLine<'static>> = message
.lines()
.map(|l| RtLine::from(l.to_string()))
.collect();
let label = RtSpan::styled(
"user".to_string(),
Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD),
);
// Render sender and content according to sender_break_line; insert message spacing if configured
let mut lines = if config.tui.sender_break_line {
let mut l = Vec::new();
// label on its own line
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 {
/// Create a user prompt cell for testing or rendering user messages.
pub fn new_user_prompt(config: &Config, message: String) -> Self {
let body: Vec<RtLine<'static>> = message
.lines()
.map(|l| RtLine::from(l.to_string()))
.collect();
let label = RtSpan::styled(
"user".to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
// Render sender and content according to sender_break_line; insert message spacing if configured
let mut lines = if config.tui.sender_break_line {
let mut l = Vec::new();
// label on its own line
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();
append_markdown(&message, &mut md_lines, config);
let label = RtSpan::styled(
"codex".to_string(),
Style::default().fg(Color::Magenta).add_modifier(Modifier::BOLD),
);
// Render sender and content according to sender_break_line; insert message spacing if configured
let mut lines = if config.tui.sender_break_line {
let mut l = Vec::new();
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 {
let label = RtSpan::styled(
"codex".to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::BOLD),
);
// Render sender and content according to sender_break_line; insert message spacing if configured
let mut lines = if config.tui.sender_break_line {
let mut l = Vec::new();
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();
append_markdown(&text, &mut md_lines, config);
let label = RtSpan::styled(
"thinking".to_string(),
Style::default().fg(Color::Magenta).add_modifier(Modifier::ITALIC),
);
// Render sender and content according to sender_break_line; insert message spacing if configured
let mut lines = if config.tui.sender_break_line {
let mut l = Vec::new();
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 {
let label = RtSpan::styled(
"thinking".to_string(),
Style::default()
.fg(Color::Magenta)
.add_modifier(Modifier::ITALIC),
);
// Render sender and content according to sender_break_line; insert message spacing if configured
let mut lines = if config.tui.sender_break_line {
let mut l = Vec::new();
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 {
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
/// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs").
pub(crate) fn new_patch_event(config: &Config, event_type: PatchEventType, changes: HashMap<PathBuf, FileChange>) -> Self {
// Handle applied patch immediately.
if let PatchEventType::ApplyBegin { auto_approved: false } = event_type {
let lines = vec![RtLine::from("patch applied".magenta().bold())];
return Self::PendingPatch { view: TextBlock::new(lines) };
}
let title = match event_type {
PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin { auto_approved: true } => "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()),
pub(crate) fn new_patch_event(
config: &Config,
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>,
) -> Self {
// Handle applied patch immediately.
if let PatchEventType::ApplyBegin {
auto_approved: false,
} = event_type
{
let lines = vec![RtLine::from("patch applied".magenta().bold())];
return Self::PendingPatch {
view: TextBlock::new(lines),
};
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) }
}
let title = match event_type {
PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin {
auto_approved: true,
} => "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
/// between the back-to-back `height()` and `render_window()` calls that the
/// 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_cells: u16,
/// Height in *terminal rows* that the conversation cell must occupy so

View File

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

View File

@@ -13,7 +13,12 @@ pub(crate) fn append_markdown(
config: &Config,
) {
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 {
for line in collapse_heading_blank_lines(new_lines) {
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 prev_was_heading = false;
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() {
continue;
}

View File

@@ -32,13 +32,15 @@ impl SlashCommand {
pub fn description(self) -> &'static str {
match self {
SlashCommand::New => "Start a new chat.",
SlashCommand::ToggleMouseMode =>
"Toggle mouse mode (enable for scrolling, disable for text selection)",
SlashCommand::EditPrompt =>
"Open external editor to edit the current prompt.",
SlashCommand::ToggleMouseMode => {
"Toggle mouse mode (enable for scrolling, disable for text selection)"
}
SlashCommand::EditPrompt => "Open external editor to edit the current prompt.",
SlashCommand::MountAdd => "Add a mount: host path → 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::Quit => "Exit the application.",
}

View File

@@ -99,8 +99,7 @@ impl WidgetRef for StatusIndicatorWidget {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
// Flush status text to the top of the input border, without extra padding
// Remove surrounding border for a cleaner in-line status display
let block = Block::default()
.padding(Padding::new(0, 0, 0, 0));
let block = Block::default().padding(Padding::new(0, 0, 0, 0));
// Animated 3dot pattern inside brackets. The *active* dot is bold
// white, the others are dim.
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`.
/// This is the default rendering backend for most `HistoryCell` variants.
/// A simple widget that displays a list of lines via a paragraph.
#[derive(Clone)]
pub(crate) struct TextBlock {
pub(crate) lines: Vec<Line<'static>>,
pub struct TextBlock {
/// The content lines to render.
pub lines: Vec<Line<'static>>,
}
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 }
}
}

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.
fn session_scoped_label(cmd: &str, max_len: usize) -> String {
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.
@@ -385,7 +388,9 @@ impl WidgetRef for &UserApprovalWidget<'_> {
.enumerate()
.map(|(idx, opt)| {
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 {
opt.label.to_string()
};
@@ -423,10 +428,10 @@ impl WidgetRef for &UserApprovalWidget<'_> {
mod tests {
use super::*;
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use std::sync::mpsc;
use ratatui::buffer::{Buffer, Cell};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Color, Style};
use ratatui::style::Color;
use std::sync::mpsc;
#[test]
fn esc_in_input_mode_cancels_input_and_preserves_value() {
@@ -473,7 +478,10 @@ mod tests {
#[test]
fn test_session_scoped_label_embeds_snippet() {
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 truncated = session_scoped_label(&long_cmd, 10);
assert!(truncated.starts_with("Yes, always allow running `"));
@@ -515,8 +523,20 @@ mod tests {
for row in area.y..area.y + area.height {
for col in area.x..area.x + area.width {
let cell = buf.cell((col, row)).unwrap();
assert_ne!(cell.bg, Color::Reset, "Found transparent cell at ({}, {})", col, row);
assert_ne!(cell.bg, Color::Red, "Found unfilled cell at ({}, {})", col, row);
assert_ne!(
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_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]
fn test_approximate_tokens_used_texts() {
// 4 chars -> 1 token
let items = vec![ResponseItem::Message {
role: "user".into(),
content: vec![ContentItem::InputText { text: "abcd".into() }],
content: vec![ContentItem::InputText {
text: "abcd".into(),
}],
}];
assert_eq!(approximate_tokens_used(&items), 1);
// 7 chars -> 2 tokens
let items = vec![ResponseItem::Message {
role: "assistant".into(),
content: vec![ContentItem::OutputText { text: "example".into() }],
content: vec![ContentItem::OutputText {
text: "example".into(),
}],
}];
assert_eq!(approximate_tokens_used(&items), 2);
}

View File

@@ -1,6 +1,6 @@
use tempfile::TempDir;
use codex_core::config::{Config, ConfigToml, ConfigOverrides};
use codex_core::config::{Config, ConfigOverrides, ConfigToml};
use codex_tui::history_cell::HistoryCell;
use tempfile::TempDir;
/// Extract plain string content of each line for comparison.
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 {
expected.push(String::new());
}
assert_eq!(got, expected,
assert_eq!(
got, expected,
"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 {
expected.push(String::new());
}
assert_eq!(got, expected,
assert_eq!(
got, expected,
"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 {
expected.push(String::new());
}
assert_eq!(got, expected,
assert_eq!(
got, expected,
"Reasoning layout mismatch for sender_break_line={}, message_spacing={}",
sender_break, message_spacing);
sender_break, message_spacing
);
}
}
}