mirror of
https://github.com/openai/codex.git
synced 2026-02-01 22:47:52 +00:00
wip
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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:?}");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 auto‑approved 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) {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.0–100.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);
|
||||
|
||||
@@ -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 {
|
||||
/// High‑level 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 file‑level 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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
}
|
||||
|
||||
@@ -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 3‑dot pattern inside brackets. The *active* dot is bold
|
||||
// white, the others are dim.
|
||||
const DOT_COUNT: usize = 3;
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user