revert(tui): keep forked TUI; defer upstream integration

This commit is contained in:
James Peter
2025-09-03 20:03:01 +10:00
parent 0ff794ed6b
commit 2f34578e6d
48 changed files with 421 additions and 1288 deletions
+1 -10
View File
@@ -786,7 +786,7 @@ dependencies = [
"clap",
"codex-common",
"codex-core",
"codex-protocol",
"codex-login",
"reqwest",
"serde",
"serde_json",
@@ -841,7 +841,6 @@ dependencies = [
"chrono",
"codex-apply-patch",
"codex-browser",
"codex-login",
"codex-mcp-client",
"codex-protocol",
"core_test_support",
@@ -1102,13 +1101,11 @@ dependencies = [
"diffy",
"image 0.25.7",
"insta",
"itertools 0.14.0",
"lazy_static",
"libc",
"mcp-types",
"once_cell",
"path-clean",
"pathdiff",
"pretty_assertions",
"rand 0.9.2",
"ratatui",
@@ -3575,12 +3572,6 @@ dependencies = [
"once_cell",
]
[[package]]
name = "pathdiff"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3"
[[package]]
name = "percent-encoding"
version = "2.3.2"
+2 -2
View File
@@ -113,8 +113,8 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch {
is_shell && is_flag && starts_with_apply
} =>
{
match extract_heredoc_body_from_apply_patch_command(script) {
Ok(body) => match parse_patch(&body) {
match extract_apply_patch_from_bash(script) {
Ok((body, _maybe_cd)) => match parse_patch(&body) {
Ok(source) => MaybeApplyPatch::Body(source),
Err(e) => MaybeApplyPatch::PatchParseError(e),
},
-1
View File
@@ -19,7 +19,6 @@ bytes = "1.10.1"
chrono = { version = "0.4", features = ["serde"] }
codex-apply-patch = { path = "../apply-patch" }
codex-browser = { path = "../browser" }
codex-login = { path = "../login" }
codex-mcp-client = { path = "../mcp-client" }
codex-protocol = { path = "../protocol" }
dirs = "6"
+1 -3
View File
@@ -108,9 +108,7 @@ pub(crate) fn convert_apply_patch_to_protocol(
ApplyPatchFileChange::Add { content } => FileChange::Add {
content: content.clone(),
},
ApplyPatchFileChange::Delete { content } => FileChange::Delete {
content: content.clone(),
},
ApplyPatchFileChange::Delete { .. } => FileChange::Delete {},
ApplyPatchFileChange::Update {
unified_diff,
move_path,
+47 -34
View File
@@ -73,10 +73,10 @@ pub(crate) async fn stream_chat_completions(
// Find the last user message index in the input.
let mut last_user_index: Option<usize> = None;
for (idx, item) in input.iter().enumerate() {
if let ResponseItem::Message { role, .. } = item
&& role == "user"
{
last_user_index = Some(idx);
if let ResponseItem::Message { role, .. } = item {
if role == "user" {
last_user_index = Some(idx);
}
}
}
@@ -84,10 +84,10 @@ pub(crate) async fn stream_chat_completions(
if !matches!(last_emitted_role, Some("user")) {
for (idx, item) in input.iter().enumerate() {
// Only consider reasoning that appears after the last user message.
if let Some(u_idx) = last_user_index
&& idx <= u_idx
{
continue;
if let Some(u_idx) = last_user_index {
if idx <= u_idx {
continue;
}
}
if let ResponseItem::Reasoning {
@@ -108,15 +108,16 @@ pub(crate) async fn stream_chat_completions(
// Prefer immediate previous assistant message (stop turns)
let mut attached = false;
if idx > 0
&& let ResponseItem::Message { role, .. } = &input[idx - 1]
&& role == "assistant"
{
reasoning_by_anchor_index
.entry(idx - 1)
.and_modify(|v| v.push_str(&text))
.or_insert(text.clone());
attached = true;
if idx > 0 {
if let ResponseItem::Message { role, .. } = &input[idx - 1] {
if role == "assistant" {
reasoning_by_anchor_index
.entry(idx - 1)
.and_modify(|v| v.push_str(&text))
.or_insert(text.clone());
attached = true;
}
}
}
// Otherwise, attach to immediate next assistant anchor (tool-calls or assistant message)
@@ -208,10 +209,10 @@ pub(crate) async fn stream_chat_completions(
}
}]
});
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
&& let Some(obj) = msg.as_object_mut()
{
obj.insert("reasoning".to_string(), json!(reasoning));
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) {
if let Some(obj) = msg.as_object_mut() {
obj.insert("reasoning".to_string(), json!(reasoning));
}
}
messages.push(msg);
}
@@ -232,10 +233,10 @@ pub(crate) async fn stream_chat_completions(
"action": action,
}]
});
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx)
&& let Some(obj) = msg.as_object_mut()
{
obj.insert("reasoning".to_string(), json!(reasoning));
if let Some(reasoning) = reasoning_by_anchor_index.get(&idx) {
if let Some(obj) = msg.as_object_mut() {
obj.insert("reasoning".to_string(), json!(reasoning));
}
}
messages.push(msg);
}
@@ -598,20 +599,32 @@ async fn process_chat_sse<S>(
if !s.is_empty() {
reasoning_text.push_str(s);
let _ = tx_event
.send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string())))
.send(Ok(ResponseEvent::ReasoningContentDelta {
delta: s.to_string(),
item_id: current_item_id.clone(),
sequence_number: None,
output_index: None,
}))
.await;
}
} else if let Some(obj) = message_reasoning.as_object()
&& let Some(s) = obj
} else if let Some(obj) = message_reasoning.as_object() {
if let Some(s) = obj
.get("text")
.and_then(|v| v.as_str())
.or_else(|| obj.get("content").and_then(|v| v.as_str()))
&& !s.is_empty()
{
reasoning_text.push_str(s);
let _ = tx_event
.send(Ok(ResponseEvent::ReasoningContentDelta(s.to_string())))
.await;
{
if !s.is_empty() {
reasoning_text.push_str(s);
let _ = tx_event
.send(Ok(ResponseEvent::ReasoningContentDelta {
delta: s.to_string(),
item_id: current_item_id.clone(),
sequence_number: None,
output_index: None,
}))
.await;
}
}
}
}
+5 -2
View File
@@ -5,8 +5,7 @@ use std::time::Duration;
use crate::AuthManager;
use bytes::Bytes;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::models::ResponseItem;
use eventsource_stream::Eventsource;
use futures::prelude::*;
@@ -66,6 +65,10 @@ struct Error {
resets_in_seconds: Option<u64>,
}
fn try_parse_retry_after(err: &Error) -> Option<std::time::Duration> {
err.resets_in_seconds.map(std::time::Duration::from_secs)
}
#[derive(Debug, Clone)]
pub struct ModelClient {
config: Arc<Config>,
+2 -2
View File
@@ -35,7 +35,7 @@ use tracing::info;
use tracing::trace;
use tracing::warn;
use uuid::Uuid;
use codex_login::CodexAuth;
use crate::CodexAuth;
use crate::protocol::WebSearchBeginEvent;
use crate::protocol::WebSearchCompleteEvent;
use codex_protocol::models::WebSearchAction;
@@ -1568,7 +1568,7 @@ async fn submission_loop(
// Wrap provided auth (if any) in a minimal AuthManager for client usage.
let auth_manager = auth
.as_ref()
.map(|a| codex_login::AuthManager::from_auth_for_testing(a.clone()));
.map(|a| crate::AuthManager::from_auth_for_testing(a.clone()));
let client = ModelClient::new(
config.clone(),
auth_manager,
+3 -4
View File
@@ -20,9 +20,8 @@ use crate::protocol::AskForApproval;
use crate::protocol::SandboxPolicy;
use crate::config_types::ReasoningEffort;
use crate::config_types::ReasoningSummary;
use codex_login::AuthMode;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::mcp_protocol::AuthMode;
use codex_protocol::config_types::SandboxMode;
use dirs::home_dir;
use serde::Deserialize;
use std::collections::HashMap;
@@ -893,8 +892,8 @@ impl Config {
/// Check if we're using ChatGPT authentication
fn is_using_chatgpt_auth(codex_home: &Path) -> bool {
use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_protocol::mcp_protocol::AuthMode;
use crate::CodexAuth;
match CodexAuth::from_codex_home(codex_home, AuthMode::ApiKey) {
Ok(Some(auth)) => auth.mode == AuthMode::ChatGPT,
+6 -5
View File
@@ -2,9 +2,9 @@ use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use codex_login::AuthManager;
use codex_login::AuthMode;
use codex_login::CodexAuth;
use crate::AuthManager;
use crate::CodexAuth;
use codex_protocol::mcp_protocol::AuthMode;
use tokio::sync::RwLock;
use uuid::Uuid;
@@ -131,11 +131,12 @@ impl ConversationManager {
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
) -> CodexResult<NewConversation> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
let _initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
let CodexSpawnOk {
codex,
init_id: _,
session_id: conversation_id,
} = Codex::spawn(config, auth_manager, initial_history).await?;
} = Codex::spawn(config, None).await?;
self.finalize_spawn(codex, conversation_id).await
}
+21 -14
View File
@@ -109,17 +109,21 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config)
tokio::task::spawn_blocking(move || -> Result<()> {
// Retry a few times to avoid indefinite blocking when contended.
for _ in 0..MAX_RETRIES {
match history_file.try_lock() {
match fs2::FileExt::try_lock_exclusive(&history_file) {
Ok(()) => {
// While holding the exclusive lock, write the full line.
history_file.write_all(line.as_bytes())?;
history_file.flush()?;
let _ = fs2::FileExt::unlock(&history_file);
return Ok(());
}
Err(std::fs::TryLockError::WouldBlock) => {
std::thread::sleep(RETRY_SLEEP);
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
std::thread::sleep(RETRY_SLEEP);
continue;
}
return Err(e.into());
}
Err(e) => return Err(e.into()),
}
}
@@ -237,9 +241,7 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
// Open & lock file for reading using a shared lock.
// Retry a few times to avoid indefinite blocking.
for _ in 0..MAX_RETRIES {
let lock_result = file.try_lock_shared();
match lock_result {
match fs2::FileExt::try_lock_shared(&file) {
Ok(()) => {
let reader = BufReader::new(&file);
for (idx, line_res) in reader.lines().enumerate() {
@@ -247,27 +249,32 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
Ok(l) => l,
Err(e) => {
tracing::warn!(error = %e, "failed to read line from history file");
let _ = fs2::FileExt::unlock(&file);
return None;
}
};
if idx == offset {
match serde_json::from_str::<HistoryEntry>(&line) {
Ok(entry) => return Some(entry),
let res = match serde_json::from_str::<HistoryEntry>(&line) {
Ok(entry) => Some(entry),
Err(e) => {
tracing::warn!(error = %e, "failed to parse history entry");
return None;
None
}
}
};
let _ = fs2::FileExt::unlock(&file);
return res;
}
}
let _ = fs2::FileExt::unlock(&file);
// Not found at requested offset.
return None;
}
Err(std::fs::TryLockError::WouldBlock) => {
std::thread::sleep(RETRY_SLEEP);
}
Err(e) => {
if e.kind() == std::io::ErrorKind::WouldBlock {
std::thread::sleep(RETRY_SLEEP);
continue;
}
tracing::warn!(error = %e, "failed to acquire shared lock on history file");
return None;
}
+3 -5
View File
@@ -906,11 +906,9 @@ fn simplify_once(commands: &[ParsedCommand]) -> Option<Vec<ParsedCommand>> {
}
_ => false,
}) {
if commands
.iter()
.skip(idx + 1)
.any(|pc| matches!(pc, ParsedCommand::Test { .. }))
{
// If a `cd` is followed by any command, keep the following commands and
// drop the standalone `cd` entry from the summary stream.
if commands.len() > idx + 1 {
let mut out = Vec::with_capacity(commands.len() - 1);
out.extend_from_slice(&commands[..idx]);
out.extend_from_slice(&commands[idx + 1..]);
+10 -6
View File
@@ -221,10 +221,12 @@ where
.await
.map(|ft| ft.is_dir())
.unwrap_or(false)
&& let Some(s) = entry.file_name().to_str()
&& let Some(v) = parse(s)
{
vec.push((v, entry.path()));
if let Some(s) = entry.file_name().to_str() {
if let Some(v) = parse(s) {
vec.push((v, entry.path()));
}
}
}
}
vec.sort_by_key(|(v, _)| Reverse(*v));
@@ -244,10 +246,12 @@ where
.await
.map(|ft| ft.is_file())
.unwrap_or(false)
&& let Some(s) = entry.file_name().to_str()
&& let Some(v) = parse(s, &entry.path())
{
collected.push(v);
if let Some(s) = entry.file_name().to_str() {
if let Some(v) = parse(s, &entry.path()) {
collected.push(v);
}
}
}
}
Ok(collected)
+83 -313
View File
@@ -25,8 +25,6 @@ use super::list::Cursor;
use super::list::get_conversations;
use crate::config::Config;
use crate::conversation_manager::InitialHistory;
use crate::git_info::GitInfo;
use crate::git_info::collect_git_info;
use codex_protocol::models::ResponseItem;
#[derive(Serialize, Deserialize, Clone, Default)]
@@ -34,21 +32,14 @@ pub struct SessionMeta {
pub id: Uuid,
pub timestamp: String,
pub instructions: Option<String>,
/// Absolute working directory for the session. Optional for back-compat
/// with older rollout files that did not record this information.
#[serde(default)]
pub cwd: Option<String>,
/// Model identifier for the session.
#[serde(default)]
pub model: Option<String>,
}
#[derive(Serialize)]
#[derive(Serialize, Deserialize)]
struct SessionMetaWithGit {
#[serde(flatten)]
meta: SessionMeta,
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<GitInfo>,
#[serde(skip_serializing_if = "Option::is_none")]
git: Option<serde_json::Value>,
}
#[derive(Serialize, Deserialize, Default, Clone)]
@@ -64,15 +55,6 @@ pub struct SavedSession {
pub session_id: Uuid,
}
/// Records all [`ResponseItem`]s for a session and flushes them to disk after
/// every update.
///
/// Rollouts are recorded as JSONL and can be inspected with tools such as:
///
/// ```ignore
/// $ jq -C . ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
/// $ fx ~/.codex/sessions/rollout-2025-05-07T17-24-21-5973b6c0-94b8-487b-a530-2aeb6098ae0e.jsonl
/// ```
#[derive(Clone)]
pub struct RolloutRecorder {
tx: Sender<RolloutCmd>,
@@ -86,7 +68,6 @@ enum RolloutCmd {
impl RolloutRecorder {
#[allow(dead_code)]
/// List conversations (rollout files) under the provided Codex home directory.
pub async fn list_conversations(
codex_home: &Path,
page_size: usize,
@@ -95,15 +76,12 @@ impl RolloutRecorder {
get_conversations(codex_home, page_size, cursor).await
}
/// Attempt to create a new [`RolloutRecorder`]. If the sessions directory
/// cannot be created or the rollout file cannot be opened we return the
/// error so the caller can decide whether to disable persistence.
pub async fn new(
config: &Config,
uuid: Uuid,
instructions: Option<String>,
) -> std::io::Result<Self> {
let LogFileInfo { file, session_id, timestamp, path } = create_log_file(config, uuid)?;
let LogFileInfo { file, session_id, timestamp } = create_log_file(config, uuid)?;
let timestamp_format: &[FormatItem] = format_description!(
"[year]-[month]-[day]T[hour]:[minute]:[second].[subsecond digits:3]Z"
@@ -112,55 +90,76 @@ impl RolloutRecorder {
.format(timestamp_format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
// Clone the cwd for the spawned task to collect git info asynchronously
let cwd = config.cwd.clone();
// A reasonably-sized bounded channel. If the buffer fills up the send
// future will yield, which is fine – we only need to ensure we do not
// perform *blocking* I/O on the caller's thread.
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
// Spawn a Tokio task that owns the file handle and performs async
// writes. Using `tokio::fs::File` keeps everything on the async I/O
// driver instead of blocking the runtime.
tokio::task::spawn(rollout_writer(
tokio::fs::File::from_std(file),
rx,
Some(SessionMeta {
timestamp,
id: session_id,
instructions,
cwd: Some(config.cwd.to_string_lossy().to_string()),
model: Some(config.model.clone()),
}),
cwd,
config.codex_home.clone(),
path,
Some(SessionMeta { timestamp, id: session_id, instructions }),
));
Ok(Self { tx })
}
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
let mut filtered = Vec::new();
for item in items {
match item {
// Note that function calls may look a bit strange if they are
// "fully qualified MCP tool calls," so we could consider
// reformatting them in that case.
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => filtered.push(item.clone()),
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {
// These should never be serialized.
continue;
}
pub async fn resume(path: &Path, _cwd: std::path::PathBuf) -> std::io::Result<(Self, SavedSession)> {
info!("Resuming rollout from {path:?}");
let text = tokio::fs::read_to_string(path).await?;
let mut lines = text.lines();
let first = lines
.next()
.ok_or_else(|| IoError::other("empty session file"))?;
let meta: SessionMeta = serde_json::from_str::<SessionMetaWithGit>(first)
.map(|m| m.meta)
.map_err(|e| IoError::other(format!("failed to parse session header: {e}")))?;
let mut items = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
}
let v: Value = match serde_json::from_str(line) {
Ok(v) => v,
Err(_) => continue,
};
if v.get("record_type")
.and_then(|rt| rt.as_str())
.map(|s| s == "state")
.unwrap_or(false)
{
continue;
}
if let Ok(item) = serde_json::from_value::<ResponseItem>(v.clone()) {
items.push(item);
}
}
let file = std::fs::OpenOptions::new().append(true).read(true).open(path)?;
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
tokio::task::spawn(rollout_writer(tokio::fs::File::from_std(file), rx, None));
let saved = SavedSession {
session: meta.clone(),
items: items.clone(),
state: SessionStateSnapshot::default(),
session_id: meta.id,
};
info!("Resumed rollout successfully from {path:?}");
Ok((Self { tx }, saved))
}
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
let filtered: Vec<ResponseItem> = items
.iter()
.filter(|item| matches!(
item,
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. }
))
.cloned()
.collect();
if filtered.is_empty() {
return Ok(());
}
@@ -185,7 +184,6 @@ impl RolloutRecorder {
.next()
.ok_or_else(|| IoError::other("empty session file"))?;
let mut items = Vec::new();
for line in lines {
if line.trim().is_empty() {
continue;
@@ -201,45 +199,10 @@ impl RolloutRecorder {
{
continue;
}
match serde_json::from_value::<ResponseItem>(v.clone()) {
Ok(item) => match item {
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => items.push(item),
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
},
Err(e) => {
warn!("failed to parse item: {v:?}, error: {e}");
}
if let Ok(item) = serde_json::from_value::<ResponseItem>(v.clone()) {
items.push(item);
}
}
let saved = SavedSession {
session: session.clone(),
items: items.clone(),
state: state.clone(),
session_id: session.id,
};
let file = std::fs::OpenOptions::new()
.append(true)
.read(true)
.open(path)?;
let (tx, rx) = mpsc::channel::<RolloutCmd>(256);
tokio::task::spawn(rollout_writer(
tokio::fs::File::from_std(file),
rx,
None,
cwd.clone(),
crate::config::find_codex_home().unwrap_or_else(|_| std::env::temp_dir()),
path.to_path_buf(),
));
info!("Resumed rollout successfully from {path:?}");
if items.is_empty() {
Ok(InitialHistory::New)
} else {
@@ -264,21 +227,12 @@ impl RolloutRecorder {
}
struct LogFileInfo {
/// Opened file handle to the rollout file.
file: File,
/// Session ID (also embedded in filename).
session_id: Uuid,
/// Timestamp for the start of the session.
timestamp: OffsetDateTime,
/// Absolute path to the rollout file on disk.
path: std::path::PathBuf,
}
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
// Resolve ~/.codex/sessions/YYYY/MM/DD and create it if missing.
let timestamp = OffsetDateTime::now_local()
.map_err(|e| IoError::other(format!("failed to get local time: {e}")))?;
let mut dir = config.codex_home.clone();
@@ -287,129 +241,37 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
dir.push(format!("{:02}", u8::from(timestamp.month())));
dir.push(format!("{:02}", timestamp.day()));
fs::create_dir_all(&dir)?;
// Custom format for YYYY-MM-DDThh-mm-ss. Use `-` instead of `:` for
// compatibility with filesystems that do not allow colons in filenames.
let format: &[FormatItem] =
format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
let format: &[FormatItem] = format_description!("[year]-[month]-[day]T[hour]-[minute]-[second]");
let date_str = timestamp
.format(format)
.map_err(|e| IoError::other(format!("failed to format timestamp: {e}")))?;
let filename = format!("rollout-{date_str}-{session_id}.jsonl");
let path = dir.join(filename);
let file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(&path)?;
Ok(LogFileInfo {
file,
session_id,
timestamp,
path,
})
let file = std::fs::OpenOptions::new().append(true).create(true).open(&path)?;
Ok(LogFileInfo { file, session_id, timestamp })
}
async fn rollout_writer(
file: tokio::fs::File,
mut file: tokio::fs::File,
mut rx: mpsc::Receiver<RolloutCmd>,
mut meta: Option<SessionMeta>,
cwd: std::path::PathBuf,
codex_home: std::path::PathBuf,
rollout_path: std::path::PathBuf,
) -> std::io::Result<()> {
let mut writer = JsonlWriter { file };
// If we have a meta, collect git info asynchronously and write meta first
if let Some(session_meta) = meta.take() {
let git_info = collect_git_info(&cwd).await;
let session_meta_with_git = SessionMetaWithGit {
meta: session_meta,
git: git_info,
};
// Write the SessionMeta as the first item in the file
writer.write_line(&session_meta_with_git).await?;
// Best-effort initial directory index update
if let Err(e) = append_dir_index_update(
&codex_home,
&cwd,
&session_meta_with_git,
&rollout_path,
0,
None,
) {
warn!("failed to write dir index: {e}");
}
let mut json = serde_json::to_string(&SessionMetaWithGit { meta: session_meta, git: None })?;
json.push('\n');
let _ = file.write_all(json.as_bytes()).await;
file.flush().await?;
}
// Process rollout commands
while let Some(cmd) = rx.recv().await {
match cmd {
RolloutCmd::AddItems(items) => {
// Track message count and last user snippet while writing
let mut msg_count_delta = 0usize;
let mut last_user_snippet: Option<String> = None;
for item in items {
match item {
ResponseItem::Message { .. }
| ResponseItem::LocalShellCall { .. }
| ResponseItem::FunctionCall { .. }
| ResponseItem::FunctionCallOutput { .. }
| ResponseItem::CustomToolCall { .. }
| ResponseItem::CustomToolCallOutput { .. }
| ResponseItem::Reasoning { .. } => {
// Skip internal system status messages from persistence
if let ResponseItem::Message { content, .. } = &item {
let contains_system_status = content.iter().any(|ci| match ci {
codex_protocol::models::ContentItem::InputText { text }
| codex_protocol::models::ContentItem::OutputText { text } => text.contains("== System Status =="),
_ => false,
});
if contains_system_status {
continue;
}
}
writer.write_line(&item).await?;
if let ResponseItem::Message { role, content, .. } = &item {
if role == "user" {
// Synthesize a short snippet
let mut s = String::new();
for c in content {
if let codex_protocol::models::ContentItem::InputText { text } = c {
if !text.is_empty() {
s.push_str(text);
break;
}
}
}
if s.is_empty() {
s = "(image/message)".to_string();
}
last_user_snippet = Some(s);
}
msg_count_delta = msg_count_delta.saturating_add(1);
}
}
ResponseItem::WebSearchCall { .. } | ResponseItem::Other => {}
}
}
// Best-effort index append after batch
if let Err(e) = append_dir_index_update(
&codex_home,
&cwd,
&SessionMetaWithGit { meta: SessionMeta { id: Uuid::nil(), timestamp: String::new(), instructions: None, cwd: Some(cwd.to_string_lossy().to_string()), model: None }, git: None },
&rollout_path,
msg_count_delta,
last_user_snippet,
) {
warn!("failed to write dir index: {e}");
let mut json = serde_json::to_string(&item)?;
json.push('\n');
let _ = file.write_all(json.as_bytes()).await;
}
file.flush().await?;
}
RolloutCmd::UpdateState(state) => {
#[derive(Serialize)]
@@ -418,106 +280,14 @@ async fn rollout_writer(
#[serde(flatten)]
state: &'a SessionStateSnapshot,
}
writer
.write_line(&StateLine {
record_type: "state",
state: &state,
})
.await?;
}
RolloutCmd::Shutdown { ack } => {
let _ = ack.send(());
let mut json = serde_json::to_string(&StateLine { record_type: "state", state: &state })?;
json.push('\n');
let _ = file.write_all(json.as_bytes()).await;
file.flush().await?;
}
RolloutCmd::Shutdown { ack } => { let _ = ack.send(()); }
}
}
Ok(())
}
struct JsonlWriter {
file: tokio::fs::File,
}
impl JsonlWriter {
async fn write_line(&mut self, item: &impl serde::Serialize) -> std::io::Result<()> {
let mut json = serde_json::to_string(item)?;
json.push('\n');
let _ = self.file.write_all(json.as_bytes()).await;
self.file.flush().await?;
Ok(())
}
}
#[derive(Serialize)]
struct DirIndexLine<'a> {
record_type: &'static str,
cwd: &'a str,
session_file: &'a str,
session_id: String,
created_ts: Option<&'a str>,
modified_ts: String,
message_count_delta: usize,
model: Option<&'a str>,
branch: Option<&'a str>,
last_user_snippet: Option<String>,
}
fn dir_index_path(codex_home: &std::path::Path, cwd: &std::path::Path) -> std::path::PathBuf {
let mut name = cwd.to_string_lossy().to_string();
name = name
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect();
if name.len() > 160 {
name.truncate(160);
}
let mut p = codex_home.to_path_buf();
p.push(SESSIONS_SUBDIR);
p.push("index");
p.push("by-dir");
let _ = fs::create_dir_all(&p);
p.push(format!("{}.jsonl", name));
p
}
fn append_dir_index_update(
codex_home: &std::path::Path,
cwd: &std::path::Path,
session_meta_with_git: &SessionMetaWithGit,
rollout_path: &std::path::Path,
message_count_delta: usize,
last_user_snippet: Option<String>,
) -> std::io::Result<()> {
let path = dir_index_path(codex_home, cwd);
let mut opts = std::fs::OpenOptions::new();
opts.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(&path)?;
let now = OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| "".to_string());
let meta = &session_meta_with_git.meta;
let branch = session_meta_with_git.git.as_ref().and_then(|g| g.branch.as_deref());
let sf_binding = rollout_path.to_string_lossy();
let line = DirIndexLine {
record_type: "dir_index",
cwd: meta.cwd.as_deref().unwrap_or("") ,
session_file: sf_binding.as_ref(),
session_id: meta.id.to_string(),
created_ts: Some(&meta.timestamp),
modified_ts: now,
message_count_delta,
model: meta.model.as_deref(),
branch,
last_user_snippet,
};
let s = serde_json::to_string(&line)
.map_err(|e| IoError::other(format!("failed to serialize dir index line: {e}")))?;
use std::io::Write as _;
f.write_all(s.as_bytes())?;
f.write_all(b"\n")
}
-5
View File
@@ -90,11 +90,6 @@ which = "6"
[target.'cfg(unix)'.dependencies]
libc = "0.2"
# Clipboard support via `arboard` is not available on Android/Termux.
# Only include it for non-Android targets so the crate builds on Android.
[target.'cfg(not(target_os = "android"))'.dependencies]
arboard = "3"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
+2 -1
View File
@@ -113,9 +113,10 @@ fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize,
#[cfg(all(test, feature = "legacy_tests"))]
mod tests {
use super::*;
use ratatui::text::Span;
fn line(s: &str) -> Line<'static> {
s.to_string().into()
Line::from(Span::raw(s.to_string()))
}
fn transcript_with_users(count: usize) -> Vec<Line<'static>> {
+10 -119
View File
@@ -23,7 +23,6 @@ use super::command_popup::CommandItem;
use super::command_popup::CommandPopup;
use super::file_search_popup::FileSearchPopup;
use super::paste_burst::PasteBurst;
use crate::bottom_pane::paste_burst::FlushResult;
use crate::slash_command::SlashCommand;
use codex_protocol::custom_prompts::CustomPrompt;
@@ -430,7 +429,7 @@ impl ChatComposer {
let placeholder = format!("[Pasted Content {char_count} chars]");
self.textarea.insert_str(&placeholder);
self.pending_pastes.push((placeholder, pasted));
} else if char_count > 1 && self.handle_paste_image_path(pasted.clone()) {
} else if self.handle_paste_image_path(pasted.clone()) {
self.textarea.insert_str(" ");
} else if pasted.trim().is_empty() {
// No textual content pasted — try reading an image directly from the OS clipboard.
@@ -492,7 +491,12 @@ impl ChatComposer {
#[allow(dead_code)]
pub(crate) fn flush_paste_burst_if_due(&mut self) -> bool {
self.handle_paste_burst_flush(Instant::now())
let now = Instant::now();
if let Some(pasted) = self.paste_burst.flush_if_due(now) {
let _ = self.handle_paste(pasted);
return true;
}
false
}
pub(crate) fn is_in_paste_burst(&self) -> bool {
@@ -619,11 +623,9 @@ impl ChatComposer {
KeyEvent {
code: KeyCode::Tab, ..
} => {
// Ensure popup filtering/selection reflects the latest composer text
// before applying completion.
let first_line = self.textarea.text().lines().next().unwrap_or("");
popup.on_composer_text_change(first_line.to_string());
if let Some(sel) = popup.selected_item() {
let first_line = self.textarea.text().lines().next().unwrap_or("");
match sel {
CommandItem::Builtin(cmd) => {
let starts_with_cmd = first_line
@@ -1179,30 +1181,6 @@ impl ChatComposer {
}
}
fn handle_paste_burst_flush(&mut self, now: Instant) -> bool {
match self.paste_burst.flush_if_due(now) {
FlushResult::Paste(pasted) => {
self.handle_paste(pasted);
true
}
FlushResult::Typed(ch) => {
// Mirror insert_str() behavior so popups stay in sync when a
// pending fast char flushes as normal typed input.
self.textarea.insert_str(ch.to_string().as_str());
// Keep popup sync consistent with key handling: prefer slash popup; only
// sync file popup when slash popup is NOT active.
self.sync_command_popup();
if matches!(self.active_popup, ActivePopup::Command(_)) {
self.dismissed_file_popup_token = None;
} else {
self.sync_file_search_popup();
}
true
}
FlushResult::None => false,
}
}
/// Handle generic Input events that modify the textarea content.
fn handle_input_basic(&mut self, input: KeyEvent) -> (InputResult, bool) {
let text_before = self.textarea.text().to_string();
@@ -1817,33 +1795,6 @@ mod tests {
}
}
#[test]
fn empty_enter_returns_none() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Ensure composer is empty and press Enter.
assert!(composer.textarea.text().is_empty());
let (result, _needs_redraw) =
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
match result {
InputResult::None => {}
other => panic!("expected None for empty enter, got: {other:?}"),
}
}
#[test]
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
use crossterm::event::KeyCode;
@@ -1946,66 +1897,6 @@ mod tests {
}
}
#[test]
fn slash_popup_model_first_for_mo_ui() {
use insta::assert_snapshot;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
// Type "/mo" humanlike so paste-burst doesn’t interfere.
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
let mut terminal = match Terminal::new(TestBackend::new(60, 4)) {
Ok(t) => t,
Err(e) => panic!("Failed to create terminal: {e}"),
};
terminal
.draw(|f| f.render_widget_ref(composer, f.area()))
.unwrap_or_else(|e| panic!("Failed to draw composer: {e}"));
// Visual snapshot should show the slash popup with /model as the first entry.
assert_snapshot!("slash_popup_mo", terminal.backend());
}
#[test]
fn slash_popup_model_first_for_mo_logic() {
use super::super::command_popup::CommandItem;
let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);
type_chars_humanlike(&mut composer, &['/', 'm', 'o']);
match &composer.active_popup {
ActivePopup::Command(popup) => match popup.selected_item() {
Some(CommandItem::Builtin(cmd)) => {
assert_eq!(cmd.command(), "model")
}
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt selected for '/mo'")
}
None => panic!("no selected command for '/mo'"),
},
_ => panic!("slash popup not active after typing '/mo'"),
}
}
// Test helper: simulate human typing with a brief delay and flush the paste-burst buffer
fn type_chars_humanlike(composer: &mut ChatComposer, chars: &[char]) {
use crossterm::event::KeyCode;
@@ -2287,7 +2178,7 @@ mod tests {
composer.handle_paste(paste.clone());
composer
.textarea
.set_cursor(placeholder.len() - pos_from_end);
.set_cursor((placeholder.len() - pos_from_end) as usize);
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
let result = (
composer.textarea.text().contains(&placeholder),
@@ -269,20 +269,6 @@ mod tests {
}
}
#[test]
fn model_is_first_suggestion_for_mo() {
let mut popup = CommandPopup::new(Vec::new());
popup.on_composer_text_change("/mo".to_string());
let matches = popup.filtered_items();
match matches.first() {
Some(CommandItem::Builtin(cmd)) => assert_eq!(cmd.command(), "model"),
Some(CommandItem::UserPrompt(_)) => {
panic!("unexpected prompt ranked before '/model' for '/mo'")
}
None => panic!("expected at least one match for '/mo'"),
}
}
#[test]
fn prompt_discovery_lists_custom_prompts() {
let prompts = vec![
@@ -4,7 +4,8 @@ use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::{Paragraph, Block, Borders, Clear};
@@ -43,7 +44,7 @@ pub(crate) struct ListSelectionView {
impl ListSelectionView {
fn dim_prefix_span() -> Span<'static> {
"▌ ".dim()
Span::styled("▌ ", Style::default().add_modifier(Modifier::DIM))
}
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
@@ -216,15 +217,7 @@ impl BottomPaneView<'_> for ListSelectionView {
})
.collect();
if rows_area.height > 0 {
render_rows(
rows_area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
true,
"no matches",
);
render_rows(rows_area, buf, &rows, &self.state, MAX_POPUP_ROWS, true);
}
if self.footer_hint.is_some() {
+5 -11
View File
@@ -36,12 +36,6 @@ pub(crate) struct RetroGrab {
pub grabbed: String,
}
pub(crate) enum FlushResult {
Paste(String),
Typed(char),
None,
}
impl PasteBurst {
/// Recommended delay to wait between simulated keypresses (or before
/// scheduling a UI tick) so that a pending fast keystroke is flushed
@@ -102,24 +96,24 @@ impl PasteBurst {
/// now emit that char as normal typed input.
///
/// Returns None if the timeout has not elapsed or there is nothing to flush.
pub fn flush_if_due(&mut self, now: Instant) -> FlushResult {
pub fn flush_if_due(&mut self, now: Instant) -> Option<String> {
let timed_out = self
.last_plain_char_time
.is_some_and(|t| now.duration_since(t) > PASTE_BURST_CHAR_INTERVAL);
if timed_out && self.is_active_internal() {
self.active = false;
let out = std::mem::take(&mut self.buffer);
FlushResult::Paste(out)
Some(out)
} else if timed_out {
// If we were saving a single fast char and no burst followed,
// flush it as normal typed input.
if let Some((ch, _at)) = self.pending_first_char.take() {
FlushResult::Typed(ch)
Some(ch.to_string())
} else {
FlushResult::None
None
}
} else {
FlushResult::None
None
}
}
@@ -3,7 +3,6 @@ use ratatui::layout::Rect;
use ratatui::prelude::Constraint;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Cell;
@@ -34,13 +33,13 @@ pub(crate) fn render_rows(
state: &ScrollState,
max_results: usize,
_dim_non_selected: bool,
empty_message: &str,
) {
let mut rows: Vec<Row> = Vec::new();
if rows_all.is_empty() {
rows.push(Row::new(vec![Cell::from(Line::from(
empty_message.dim().italic(),
))]));
rows.push(Row::new(vec![Cell::from(Line::from(Span::styled(
"no matches",
Style::default().add_modifier(Modifier::ITALIC | Modifier::DIM),
)))]));
} else {
let max_rows_from_area = area.height as usize;
let visible_rows = max_results
@@ -85,10 +84,9 @@ pub(crate) fn render_rows(
}
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
idx_iter.next();
spans.push(ch.to_string().bold());
} else {
spans.push(ch.to_string().into());
style = style.add_modifier(Modifier::BOLD);
}
spans.push(Span::styled(ch.to_string(), style));
}
} else {
let mut style = Style::default();
@@ -99,8 +97,11 @@ pub(crate) fn render_rows(
}
if let Some(desc) = description.as_ref() {
spans.push(" ".into());
spans.push(desc.clone().dim());
spans.push(Span::raw(" "));
spans.push(Span::styled(
desc.clone(),
Style::default().add_modifier(Modifier::DIM),
));
}
let mut cell = Cell::from(Line::from(spans));
+1
View File
@@ -236,6 +236,7 @@ impl TextArea {
} => self.delete_backward_word(),
KeyEvent {
code: KeyCode::Backspace,
modifiers: KeyModifiers::NONE,
..
}
| KeyEvent {
@@ -3,7 +3,6 @@ source: tui/src/chatwidget/tests.rs
assertion_line: 728
expression: terminal.backend()
---
" "
"? Codex wants to run echo hello world "
" "
"Model wants to run a command "
@@ -1,9 +1,8 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 794
assertion_line: 763
expression: terminal.backend()
---
" "
"The model wants to apply changes "
" "
"This will grant write access to /tmp for the remainder of this session. "
@@ -1,6 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 779
expression: terminal.backend()
---
"▌ Ask Codex to do anything "
" ⏎ send Ctrl+J newline Ctrl+T transc"
" "
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 779
expression: terminal.backend()
---
" "
@@ -1,6 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 807
expression: terminal.backend()
---
" Thinking (0s • Esc to interrupt) "
"▌ Ask Codex to do anything "
" "
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 807
expression: terminal.backend()
---
" "
@@ -2,4 +2,5 @@
source: tui/src/chatwidget/tests.rs
expression: exec_blob
---
• Ran sleep 1
>_
✗ ⌨️ sleep 1
@@ -1,5 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 878
expression: terminal.backend()
---
" "
@@ -1,9 +1,8 @@
---
source: tui/src/chatwidget/tests.rs
assertion_line: 921
assertion_line: 851
expression: terminal.backend()
---
" "
"? Codex wants to run echo 'hello world' "
" "
"Codex wants to run a command "
+11 -158
View File
@@ -3,7 +3,6 @@
use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use codex_core::CodexAuth;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
@@ -33,6 +32,7 @@ use pretty_assertions::assert_eq;
use std::fs::File;
use std::io::BufRead;
use std::io::BufReader;
use std::io::Read;
use std::path::PathBuf;
use std::sync::mpsc::channel;
use tokio::sync::mpsc::unbounded_channel;
@@ -142,6 +142,7 @@ fn make_chatwidget_manual() -> (
stream: StreamController::new(cfg),
last_stream_kind: None,
running_commands: HashMap::new(),
pending_exec_completions: Vec::new(),
task_complete_pending: false,
interrupts: InterruptManager::new(),
needs_redraw: false,
@@ -172,56 +173,6 @@ fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String {
s
}
// --- Small helpers to tersely drive exec begin/end and snapshot active cell ---
fn begin_exec(chat: &mut ChatWidget, call_id: &str, raw_cmd: &str) {
// Build the full command vec and parse it using core's parser,
// then convert to protocol variants for the event payload.
let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()];
let parsed_cmd: Vec<codex_protocol::parse_command::ParsedCommand> =
codex_core::parse_command::parse_command(&command)
.into_iter()
.map(Into::into)
.collect();
chat.handle_codex_event(Event {
id: call_id.to_string(),
msg: EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: call_id.to_string(),
command,
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
parsed_cmd,
}),
});
}
fn end_exec(chat: &mut ChatWidget, call_id: &str, stdout: &str, stderr: &str, exit_code: i32) {
let aggregated = if stderr.is_empty() {
stdout.to_string()
} else {
format!("{stdout}{stderr}")
};
chat.handle_codex_event(Event {
id: call_id.to_string(),
msg: EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: call_id.to_string(),
stdout: stdout.to_string(),
stderr: stderr.to_string(),
aggregated_output: aggregated.clone(),
exit_code,
duration: std::time::Duration::from_millis(5),
formatted_output: aggregated,
}),
});
}
fn active_blob(chat: &ChatWidget) -> String {
let lines = chat
.active_exec_cell
.as_ref()
.expect("active exec cell present")
.display_lines(80);
lines_to_single_string(&lines)
}
fn open_fixture(name: &str) -> std::fs::File {
// 1) Prefer fixtures within this crate
{
@@ -246,20 +197,6 @@ fn open_fixture(name: &str) -> std::fs::File {
File::open(name).expect("open fixture file")
}
#[test]
fn empty_enter_during_task_does_not_queue() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
// Simulate running task so submissions would normally be queued.
chat.bottom_pane.set_task_running(true);
// Press Enter with an empty composer.
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
// Ensure nothing was queued.
assert!(chat.queued_user_messages.is_empty());
}
#[test]
fn alt_up_edits_most_recent_queued_message() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
@@ -374,7 +311,6 @@ fn exec_history_cell_shows_working_then_failed() {
blob.contains("Failed (exit 2)"),
"expected completed exec cell to show Failed header with exit code: {blob:?}"
);
assert!(blob.to_lowercase().contains("bloop"), "expected error text");
}
#[tokio::test(flavor = "current_thread")]
@@ -395,7 +331,6 @@ async fn binary_size_transcript_matches_ideal_fixture() {
let reader = BufReader::new(file);
let mut transcript = String::new();
let mut ansi: Vec<u8> = Vec::new();
let mut has_emitted_history = false;
for line in reader.lines() {
let line = line.expect("read line");
@@ -548,33 +483,6 @@ fn approval_modal_exec_snapshot() {
assert_snapshot!("approval_modal_exec", terminal.backend());
}
// Snapshot test: command approval modal without a reason
// Ensures spacing looks correct when no reason text is provided.
#[test]
fn approval_modal_exec_without_reason_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual();
chat.config.approval_policy = codex_core::protocol::AskForApproval::OnRequest;
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-cmd-noreason".into(),
command: vec!["bash".into(), "-lc".into(), "echo hello world".into()],
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")),
reason: None,
};
chat.handle_codex_event(Event {
id: "sub-approve-noreason".into(),
msg: EventMsg::ExecApprovalRequest(ev),
});
let height = chat.desired_height(80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
terminal
.draw(|f| f.render_widget_ref(&chat, f.area()))
.expect("draw approval modal (no reason)");
assert_snapshot!("approval_modal_exec_no_reason", terminal.backend());
}
// Snapshot test: patch approval modal
#[test]
fn approval_modal_patch_snapshot() {
@@ -796,11 +704,11 @@ fn apply_patch_events_emit_history_cells() {
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Proposed Change"),
"missing proposed change header: {blob:?}"
blob.contains("proposed patch"),
"missing proposed patch header: {blob:?}"
);
// 2) Begin apply -> per-file apply block cell (no global header)
// 2) Begin apply -> applying patch cell
let mut changes2 = HashMap::new();
changes2.insert(
PathBuf::from("foo.txt"),
@@ -821,8 +729,8 @@ fn apply_patch_events_emit_history_cells() {
assert!(!cells.is_empty(), "expected applying patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Added foo.txt") || blob.contains("Edited foo.txt"),
"expected single-file header with filename (Added/Edited): {blob:?}"
blob.contains("Applying patch"),
"missing applying patch header: {blob:?}"
);
// 3) End apply success -> success cell
@@ -840,63 +748,8 @@ fn apply_patch_events_emit_history_cells() {
assert!(!cells.is_empty(), "expected applied patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Change Approved foo.txt"),
"expected change approved summary: {blob:?}"
);
}
#[test]
fn apply_patch_manual_flow_snapshot() {
use insta::assert_snapshot;
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
let mut proposed_changes = HashMap::new();
proposed_changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
);
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent {
call_id: "c1".into(),
changes: proposed_changes,
reason: Some("Manual review required".into()),
grant_root: None,
}),
});
let proposed_lines = drain_insert_history(&mut rx)
.pop()
.expect("proposed patch cell");
let mut apply_changes = HashMap::new();
apply_changes.insert(
PathBuf::from("foo.txt"),
FileChange::Add {
content: "hello\n".to_string(),
},
);
chat.handle_codex_event(Event {
id: "s1".into(),
msg: EventMsg::PatchApplyBegin(PatchApplyBeginEvent {
call_id: "c1".into(),
auto_approved: false,
changes: apply_changes,
}),
});
let approved_lines = drain_insert_history(&mut rx)
.pop()
.expect("approved patch cell");
assert_snapshot!(
"apply_patch_manual_flow_history_proposed",
lines_to_single_string(&proposed_lines)
);
assert_snapshot!(
"apply_patch_manual_flow_history_approved",
lines_to_single_string(&approved_lines)
blob.contains("Applied patch"),
"missing applied patch header: {blob:?}"
);
}
@@ -1093,7 +946,7 @@ fn apply_patch_request_shows_diff_summary() {
// Header should summarize totals
assert!(
blob.contains("Proposed Change README.md (+2 -0)"),
blob.contains("proposed patch to 1 file (+2 -0)"),
"missing or incorrect diff header: {blob:?}"
);
@@ -1132,7 +985,7 @@ fn plan_update_renders_history_cell() {
assert!(!cells.is_empty(), "expected plan update cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
blob.contains("Updated Plan"),
blob.contains("Update plan"),
"missing plan header: {blob:?}"
);
assert!(blob.contains("Explore codebase"));
+4 -11
View File
@@ -37,7 +37,6 @@ pub struct PastedImageInfo {
}
/// Capture image from system clipboard, encode to PNG, and return bytes + info.
#[cfg(not(target_os = "android"))]
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
tracing::debug!("attempting clipboard image read");
let mut cb = arboard::Clipboard::new()
@@ -61,23 +60,17 @@ pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageErro
.map_err(|e| PasteImageError::EncodeFailed(e.to_string()))?;
}
tracing::debug!("clipboard image encoded to PNG ({}) bytes", png.len());
tracing::debug!(
"clipboard image encoded to PNG ({len} bytes)",
len = png.len()
);
Ok((
png,
PastedImageInfo { width: w, height: h, encoded_format: EncodedImageFormat::Png },
))
}
/// Android/Termux does not support arboard; return a clear error.
#[cfg(target_os = "android")]
pub fn paste_image_as_png() -> Result<(Vec<u8>, PastedImageInfo), PasteImageError> {
Err(PasteImageError::ClipboardUnavailable(
"clipboard image paste is unsupported on Android".into(),
))
}
/// Convenience: write to a temp file and return its path + info.
#[cfg(not(target_os = "android"))]
pub fn paste_image_to_temp_png() -> Result<(PathBuf, PastedImageInfo), PasteImageError> {
let (png, info) = paste_image_as_png()?;
// Create a unique temporary file with a .png suffix to avoid collisions.
+58 -334
View File
@@ -2,18 +2,14 @@ use crossterm::terminal;
// Color type is already in scope at the top of this module
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line as RtLine;
use ratatui::text::Span as RtSpan;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use codex_core::protocol::FileChange;
use crate::history_cell::PatchEventType;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::FileChange;
// Sanitize diff content so tabs and control characters don’t break terminal layout.
// Mirrors the behavior we use for user input and command output:
@@ -101,8 +97,6 @@ pub(super) fn create_diff_summary(
title: &str,
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
cwd: &Path,
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
create_diff_summary_with_width(title, changes, event_type, None)
}
@@ -147,74 +141,51 @@ pub(super) fn create_diff_summary_with_width(
_ => {}
}
}
(adds, dels)
}
PatchEventType::ApprovalRequest => HeaderKind::ProposedChange,
};
render_changes_block(rows, wrap_cols, header_kind, cwd)
}
// Shared row for per-file presentation
#[derive(Clone)]
struct Row {
#[allow(dead_code)]
path: PathBuf,
move_path: Option<PathBuf>,
added: usize,
removed: usize,
change: FileChange,
}
fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
let mut rows: Vec<Row> = Vec::new();
let mut files: Vec<FileSummary> = Vec::new();
for (path, change) in changes.iter() {
let (added, removed) = match change {
FileChange::Add { content } => (content.lines().count(), 0),
FileChange::Delete { content } => (0, content.lines().count()),
FileChange::Update { unified_diff, .. } => calculate_add_remove_from_diff(unified_diff),
};
let move_path = match change {
match change {
FileChange::Add { content } => files.push(FileSummary {
display_path: path.display().to_string(),
added: content.lines().count(),
removed: 0,
}),
FileChange::Delete => files.push(FileSummary {
display_path: path.display().to_string(),
added: 0,
removed: std::fs::read_to_string(path)
.ok()
.map(|s| s.lines().count())
.unwrap_or(0),
}),
FileChange::Update {
move_path: Some(new),
..
} => Some(new.clone()),
_ => None,
};
rows.push(Row {
path: path.clone(),
move_path,
added,
removed,
change: change.clone(),
});
unified_diff,
move_path,
} => {
let (added, removed) = count_from_unified(unified_diff);
let display_path = if let Some(new_path) = move_path {
format!("{} → {}", path.display(), new_path.display())
} else {
path.display().to_string()
};
files.push(FileSummary {
display_path,
added,
removed,
});
}
}
}
rows.sort_by_key(|r| r.path.clone());
rows
}
enum HeaderKind {
ProposedChange,
Edited,
ChangeApproved,
}
let file_count = files.len();
let total_added: usize = files.iter().map(|f| f.added).sum();
let total_removed: usize = files.iter().map(|f| f.removed).sum();
let noun = if file_count == 1 { "file" } else { "files" };
fn render_changes_block(
rows: Vec<Row>,
wrap_cols: usize,
header_kind: HeaderKind,
cwd: &Path,
) -> Vec<RtLine<'static>> {
let mut out: Vec<RtLine<'static>> = Vec::new();
let term_cols = wrap_cols;
fn render_line_count_summary(added: usize, removed: usize) -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
spans.push("(".into());
spans.push(format!("+{added}").green());
spans.push(" ".into());
spans.push(format!("-{removed}").red());
spans.push(")".into());
spans
}
// Header
let mut header_spans: Vec<RtSpan<'static>> = Vec::new();
@@ -274,7 +245,6 @@ fn render_changes_block(
spans.push(RtSpan::styled(")".to_string(), Style::default().fg(crate::colors::text_dim())));
out.push(RtLine::from(spans));
}
out.push(RtLine::from(header_spans));
let show_details = matches!(
event_type,
@@ -321,20 +291,7 @@ fn render_patch_details_with_width(
RtSpan::styled("...", style_dim()),
]));
}
// File header line (skip when single-file header already shows the name)
let skip_file_header =
matches!(header_kind, HeaderKind::ProposedChange | HeaderKind::Edited)
&& file_count == 1;
if !skip_file_header {
let mut header: Vec<RtSpan<'static>> = Vec::new();
header.push(" └ ".dim());
header.extend(render_path(&r));
header.push(" ".into());
header.extend(render_line_count_summary(r.added, r.removed));
out.push(RtLine::from(header));
}
match r.change {
match change {
FileChange::Add { content } => {
for (i, raw) in content.lines().enumerate() {
let ln = i + 1;
@@ -360,12 +317,20 @@ fn render_patch_details_with_width(
));
}
}
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(&unified_diff) {
FileChange::Update {
unified_diff,
move_path: _,
} => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
let mut is_first_hunk = true;
for h in patch.hunks() {
// Render a simple separator between non-contiguous hunks
// instead of diff-style @@ headers.
if !is_first_hunk {
out.push(RtLine::from(vec![" ".into(), "⋮".dim()]));
out.push(RtLine::from(vec![
RtSpan::raw(" "),
RtSpan::styled("⋮", style_dim()),
]));
}
is_first_hunk = false;
@@ -628,12 +593,6 @@ mod tests {
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
fn diff_summary_for_tests(
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
) -> Vec<RtLine<'static>> {
create_diff_summary(changes, event_type, &PathBuf::from("/"), 80)
}
fn snapshot_lines(name: &str, lines: Vec<RtLine<'static>>, width: u16, height: u16) {
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("terminal");
@@ -647,23 +606,6 @@ mod tests {
assert_snapshot!(name, terminal.backend());
}
fn snapshot_lines_text(name: &str, lines: &[RtLine<'static>]) {
// Convert Lines to plain text rows and trim trailing spaces so it's
// easier to validate indentation visually in snapshots.
let text = lines
.iter()
.map(|l| {
l.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<String>()
})
.map(|s| s.trim_end().to_string())
.collect::<Vec<_>>()
.join("\n");
assert_snapshot!(name, text);
}
#[test]
fn ui_snapshot_add_details() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
@@ -674,7 +616,8 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
snapshot_lines("add_details", lines, 80, 10);
}
@@ -695,7 +638,8 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
snapshot_lines("update_details_with_rename", lines, 80, 12);
}
@@ -735,7 +679,8 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
}
@@ -756,7 +701,8 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
snapshot_lines("blank_context_line", lines, 80, 10);
}
@@ -778,232 +724,10 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
let lines =
create_diff_summary("proposed patch", &changes, PatchEventType::ApprovalRequest);
// Height is large enough to show both hunks and the separator
snapshot_lines("vertical_ellipsis_between_hunks", lines, 80, 16);
}
#[test]
fn ui_snapshot_apply_update_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "line one\nline two\nline three\n";
let modified = "line one\nline two changed\nline three\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
for (name, auto_approved) in [
("apply_update_block", true),
("apply_update_block_manual", false),
] {
let lines =
diff_summary_for_tests(&changes, PatchEventType::ApplyBegin { auto_approved });
snapshot_lines(name, lines, 80, 12);
}
}
#[test]
fn ui_snapshot_apply_update_with_rename_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
let original = "A\nB\nC\n";
let modified = "A\nB changed\nC\n";
let patch = diffy::create_patch(original, modified).to_string();
changes.insert(
PathBuf::from("old_name.rs"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("new_name.rs")),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_multiple_files_block() {
// Two files: one update and one add, to exercise combined header and per-file rows
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
// File a.txt: single-line replacement (one delete, one insert)
let patch_a = diffy::create_patch("one\n", "one changed\n").to_string();
changes.insert(
PathBuf::from("a.txt"),
FileChange::Update {
unified_diff: patch_a,
move_path: None,
},
);
// File b.txt: newly added with one line
changes.insert(
PathBuf::from("b.txt"),
FileChange::Add {
content: "new\n".to_string(),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
}
#[test]
fn ui_snapshot_apply_add_block() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("new_file.txt"),
FileChange::Add {
content: "alpha\nbeta\n".to_string(),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_add_block", lines, 80, 10);
}
#[test]
fn ui_snapshot_apply_delete_block() {
// Write a temporary file so the delete renderer can read original content
let tmp_path = PathBuf::from("tmp_delete_example.txt");
std::fs::write(&tmp_path, "first\nsecond\nthird\n").expect("write tmp file");
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
tmp_path.clone(),
FileChange::Delete {
content: "first\nsecond\nthird\n".to_string(),
},
);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
// Cleanup best-effort; rendering has already read the file
let _ = std::fs::remove_file(&tmp_path);
snapshot_lines("apply_delete_block", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_update_block_wraps_long_lines() {
// Create a patch with a long modified line to force wrapping
let original = "line 1\nshort\nline 3\n";
let modified = "line 1\nshort this_is_a_very_long_modified_line_that_should_wrap_across_multiple_terminal_columns_and_continue_even_further_beyond_eighty_columns_to_force_multiple_wraps\nline 3\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("long_example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
72,
);
// Render with backend width wider than wrap width to avoid Paragraph auto-wrap.
snapshot_lines("apply_update_block_wraps_long_lines", lines, 80, 12);
}
#[test]
fn ui_snapshot_apply_update_block_wraps_long_lines_text() {
// This mirrors the desired layout example: sign only on first inserted line,
// subsequent wrapped pieces start aligned under the line number gutter.
let original = "1\n2\n3\n4\n";
let modified = "1\nadded long line which wraps and_if_there_is_a_long_token_it_will_be_broken\n3\n4 context line which also wraps across\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("wrap_demo.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let mut lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&PathBuf::from("/"),
28,
);
// Drop the combined header for this text-only snapshot
if !lines.is_empty() {
lines.remove(0);
}
snapshot_lines_text("apply_update_block_wraps_long_lines_text", &lines);
}
#[test]
fn ui_snapshot_apply_update_block_relativizes_path() {
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/"));
let abs_old = cwd.join("abs_old.rs");
let abs_new = cwd.join("abs_new.rs");
let original = "X\nY\n";
let modified = "X changed\nY\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
abs_old.clone(),
FileChange::Update {
unified_diff: patch,
move_path: Some(abs_new.clone()),
},
);
let lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&cwd,
80,
);
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
}
}
+16 -103
View File
@@ -6,8 +6,6 @@ use base64::Engine;
use codex_ansi_escape::ansi_escape_line;
use codex_common::create_config_summary_entries;
use codex_common::elapsed::format_duration;
use codex_core::auth::get_auth_file;
use codex_core::auth::try_read_auth_json;
use codex_core::config::Config;
use codex_core::config_types::ReasoningEffort;
use codex_core::parse_command::ParsedCommand;
@@ -20,7 +18,6 @@ use codex_core::protocol::SessionConfiguredEvent;
use codex_core::protocol::TokenUsage;
use image::DynamicImage;
use image::ImageReader;
use itertools::Itertools;
use mcp_types::EmbeddedResourceResource;
use mcp_types::ResourceLink;
use ratatui::prelude::*;
@@ -34,7 +31,6 @@ use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
use std::collections::HashMap;
use std::io::Cursor;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use std::time::Instant;
@@ -5137,66 +5133,6 @@ pub(crate) fn new_completed_web_fetch_tool_call(
lines,
state: if success { ToolState::Success } else { ToolState::Failed },
}
pub(crate) fn new(call: ExecCall) -> Self {
ExecCell { calls: vec![call] }
}
fn is_exploring_call(call: &ExecCall) -> bool {
!call.parsed.is_empty()
&& call.parsed.iter().all(|p| {
matches!(
p,
ParsedCommand::Read { .. }
| ParsedCommand::ListFiles { .. }
| ParsedCommand::Search { .. }
)
})
}
fn is_exploring_cell(&self) -> bool {
self.calls.iter().all(Self::is_exploring_call)
}
pub(crate) fn with_added_call(
&self,
call_id: String,
command: Vec<String>,
parsed: Vec<ParsedCommand>,
) -> Option<Self> {
let call = ExecCall {
call_id,
command,
parsed,
output: None,
start_time: Some(Instant::now()),
duration: None,
};
if self.is_exploring_cell() && Self::is_exploring_call(&call) {
Some(Self {
calls: [self.calls.clone(), vec![call]].concat(),
})
} else {
None
}
}
pub(crate) fn complete_call(
&mut self,
call_id: &str,
output: CommandOutput,
duration: Duration,
) {
if let Some(call) = self.calls.iter_mut().rev().find(|c| c.call_id == call_id) {
call.output = Some(output);
call.duration = Some(duration);
call.start_time = None;
}
}
pub(crate) fn should_flush(&self) -> bool {
!self.is_exploring_cell() && self.calls.iter().all(|c| c.output.is_some())
}
}
// Map `browser_*` tool names to friendly titles
@@ -5735,14 +5671,20 @@ pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> PlainHis
// Only show Reasoning fields if present in config summary
let reff = lookup("reasoning effort");
if !reff.is_empty() {
lines.push(vec![" • Reasoning Effort: ".into(), title_case(&reff).into()].into());
lines.push(Line::from(vec![
" • Reasoning Effort: ".into(),
title_case(&reff).into(),
]));
}
let rsum = lookup("reasoning summaries");
if !rsum.is_empty() {
lines.push(vec![" • Reasoning Summaries: ".into(), title_case(&rsum).into()].into());
lines.push(Line::from(vec![
" • Reasoning Summaries: ".into(),
title_case(&rsum).into(),
]));
}
lines.push("".into());
lines.push(Line::from(""));
// 📊 Token Usage
lines.push(Line::from(vec!["📊 ".into(), "Token Usage".bold()]));
@@ -5794,8 +5736,6 @@ pub(crate) fn new_prompts_output() -> PlainHistoryCell {
pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
let UpdatePlanArgs { explanation, plan } = update;
PlanUpdateCell { explanation, plan }
}
let mut lines: Vec<Line<'static>> = Vec::new();
// Header with progress summary
@@ -5886,41 +5826,14 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
} else {
Span::raw(" ")
};
let wrap_width = (width as usize)
.saturating_sub(4)
.saturating_sub(box_str.width())
.max(1);
let parts = textwrap::wrap(text, wrap_width);
let step_text = parts
.into_iter()
.map(|s| s.to_string().set_style(step_style).into())
.collect();
prefix_lines(step_text, &box_str.into(), &" ".into())
};
fn prefix_lines(
lines: Vec<Line<'static>>,
initial_prefix: &Span<'static>,
subsequent_prefix: &Span<'static>,
) -> Vec<Line<'static>> {
lines
.into_iter()
.enumerate()
.map(|(i, l)| {
Line::from(
[
vec![if i == 0 {
initial_prefix.clone()
} else {
subsequent_prefix.clone()
}],
l.spans,
]
.concat(),
)
})
.collect()
lines.push(Line::from(vec![
prefix,
box_span,
Span::raw(" "),
text_span,
]));
}
}
PlainHistoryCell {
lines,
+1 -4
View File
@@ -340,10 +340,7 @@ impl Command for SetUnderlineColor {
}
/// Word-aware wrapping for a list of `Line`s preserving styles.
pub(crate) fn word_wrap_lines<'a, I>(lines: I, width: u16) -> Vec<Line<'static>>
where
I: IntoIterator<Item = &'a Line<'a>>,
{
pub(crate) fn word_wrap_lines(lines: &[Line], width: u16) -> Vec<Line<'static>> {
let mut out = Vec::new();
let w = width.max(1) as usize;
for line in lines {
+9 -11
View File
@@ -4,9 +4,7 @@
#![deny(clippy::print_stdout, clippy::print_stderr)]
#![deny(clippy::disallowed_methods)]
use app::App;
use codex_core::AuthManager;
use codex_core::BUILT_IN_OSS_MODEL_PROVIDER_ID;
use codex_core::CodexAuth;
use codex_core::config::Config;
use codex_core::config::ConfigOverrides;
use codex_core::config::ConfigToml;
@@ -18,7 +16,6 @@ use codex_login::AuthMode;
use codex_login::CodexAuth;
use codex_ollama::DEFAULT_OSS_MODEL;
use codex_protocol::config_types::SandboxMode;
use codex_protocol::mcp_protocol::AuthMode;
use std::fs::OpenOptions;
use std::path::PathBuf;
use tracing_appender::non_blocking;
@@ -298,6 +295,7 @@ fn run_ratatui_app(
if let Some(latest_version) = updates::get_upgrade_version(&config) {
use ratatui::style::Stylize as _;
use ratatui::text::Line;
use ratatui::text::Span;
let current_version = option_env!("CODE_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"));
let exe = std::env::current_exe()?;
@@ -306,31 +304,31 @@ fn run_ratatui_app(
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec![
"✨⬆️ Update available!".bold().cyan(),
" ".into(),
format!("{current_version} -> {latest_version}.").into(),
Span::raw(" "),
Span::raw(format!("{current_version} -> {latest_version}.")),
]));
if managed_by_npm {
let npm_cmd = "npm install -g @openai/codex@latest";
lines.push(Line::from(vec![
"Run ".into(),
Span::raw("Run "),
npm_cmd.cyan(),
" to update.".into(),
Span::raw(" to update."),
]));
} else if cfg!(target_os = "macos")
&& (exe.starts_with("/opt/homebrew") || exe.starts_with("/usr/local"))
{
let brew_cmd = "brew upgrade codex";
lines.push(Line::from(vec![
"Run ".into(),
Span::raw("Run "),
brew_cmd.cyan(),
" to update.".into(),
Span::raw(" to update."),
]));
} else {
lines.push(Line::from(vec![
"See ".into(),
Span::raw("See "),
"https://github.com/openai/codex/releases/latest".cyan(),
" for the latest releases and installation options.".into(),
Span::raw(" for the latest releases and installation options."),
]));
}
+54 -39
View File
@@ -12,6 +12,7 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
@@ -111,10 +112,13 @@ impl AuthModeWidget {
),
]),
Line::from(vec![
" ".into(),
"or connect an API key for usage-based billing".bold(),
Span::raw(" "),
Span::styled(
"or connect an API key for usage-based billing",
Style::default().add_modifier(Modifier::BOLD),
),
]),
"".into(),
Line::from(""),
];
// If the user is already authenticated but the method differs from their
@@ -149,7 +153,7 @@ impl AuthModeWidget {
text.to_string().cyan(),
])
} else {
format!(" {}. {text}", idx + 1).into()
Line::from(format!(" {}. {text}", idx + 1))
};
let line2 = if is_selected {
@@ -188,15 +192,19 @@ impl AuthModeWidget {
api_key_label,
"Pay for what you use",
));
lines.push("".into());
lines.push(Line::from(""));
lines.push(
// AE: Following styles.md, this should probably be Cyan because it's a user input tip.
// But leaving this for a future cleanup.
" Press Enter to continue".dim().into(),
Line::from(" Press Enter to continue")
.style(Style::default().add_modifier(Modifier::DIM)),
);
if let Some(err) = &self.error {
lines.push("".into());
lines.push(err.as_str().red().into());
lines.push(Line::from(""));
lines.push(Line::from(Span::styled(
err.as_str(),
Style::default().fg(Color::Red),
)));
}
Paragraph::new(lines)
@@ -205,7 +213,7 @@ impl AuthModeWidget {
}
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
let mut spans = vec!["> ".into()];
let mut spans = vec![Span::from("> ")];
// Schedule a follow-up frame to keep the shimmer animation going.
self.event_tx
.send(AppEvent::ScheduleFrameIn(std::time::Duration::from_millis(
@@ -224,7 +232,9 @@ impl AuthModeWidget {
}
}
lines.push(" Press Esc to cancel".dim().into());
lines.push(
Line::from(" Press Esc to cancel").style(Style::default().add_modifier(Modifier::DIM)),
);
Paragraph::new(lines)
.wrap(Wrap { trim: false })
.render(area, buf);
@@ -232,28 +242,35 @@ impl AuthModeWidget {
fn render_chatgpt_success_message(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ Signed in with your ChatGPT account".fg(Color::Green).into(),
"".into(),
"> Before you start:".into(),
"".into(),
" Decide how much autonomy you want to grant Codex".into(),
Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green),
Line::from(""),
Line::from("> Before you start:"),
Line::from(""),
Line::from(" Decide how much autonomy you want to grant Codex"),
Line::from(vec![
" For more details see the ".into(),
"\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}".underlined(),
Span::raw(" For more details see the "),
Span::styled(
"\u{1b}]8;;https://github.com/openai/codex\u{7}Codex docs\u{1b}]8;;\u{7}",
Style::default().add_modifier(Modifier::UNDERLINED),
),
])
.dim(),
"".into(),
" Codex can make mistakes".into(),
" Review the code it writes and commands it runs".dim().into(),
"".into(),
" Powered by your ChatGPT account".into(),
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Codex can make mistakes"),
Line::from(" Review the code it writes and commands it runs")
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Powered by your ChatGPT account"),
Line::from(vec![
" Uses your plan's rate limits and ".into(),
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}".underlined(),
Span::raw(" Uses your plan's rate limits and "),
Span::styled(
"\u{1b}]8;;https://chatgpt.com/#settings\u{7}training data preferences\u{1b}]8;;\u{7}",
Style::default().add_modifier(Modifier::UNDERLINED),
),
])
.dim(),
"".into(),
" Press Enter to continue".fg(Color::Cyan).into(),
.style(Style::default().add_modifier(Modifier::DIM)),
Line::from(""),
Line::from(" Press Enter to continue").fg(Color::Cyan),
];
Paragraph::new(lines)
@@ -262,11 +279,7 @@ impl AuthModeWidget {
}
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
"✓ Signed in with your ChatGPT account"
.fg(Color::Green)
.into(),
];
let lines = vec![Line::from("✓ Signed in with your ChatGPT account").fg(Color::Green)];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
@@ -274,7 +287,7 @@ impl AuthModeWidget {
}
fn render_env_var_found(&self, area: Rect, buf: &mut Buffer) {
let lines = vec!["✓ Using OPENAI_API_KEY".fg(Color::Green).into()];
let lines = vec![Line::from("✓ Using OPENAI_API_KEY").fg(Color::Green)];
Paragraph::new(lines)
.wrap(Wrap { trim: false })
@@ -283,11 +296,13 @@ impl AuthModeWidget {
fn render_env_var_missing(&self, area: Rect, buf: &mut Buffer) {
let lines = vec![
" To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment"
.fg(Color::Cyan)
.into(),
"".into(),
" Press Enter to return".dim().into(),
Line::from(
" To use Codex with the OpenAI API, set OPENAI_API_KEY in your environment",
)
.style(Style::default().fg(Color::Cyan)),
Line::from(""),
Line::from(" Press Enter to return")
.style(Style::default().add_modifier(Modifier::DIM)),
];
Paragraph::new(lines)
@@ -6,7 +6,7 @@ use ratatui::prelude::Widget;
use ratatui::widgets::Clear;
use ratatui::widgets::WidgetRef;
use codex_protocol::mcp_protocol::AuthMode;
use codex_login::AuthMode;
use crate::LoginStatus;
use crate::app::ChatWidgetArgs;
@@ -84,7 +84,7 @@ impl OnboardingScreen {
preferred_auth_method: codex_login::AuthMode::ApiKey,
}))
}
let is_git_repo = get_git_repo_root(&cwd).is_some();
let is_git_repo = is_inside_git_repo(&cwd);
let highlighted = if is_git_repo {
TrustDirectorySelection::Trust
} else {
@@ -9,8 +9,10 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
@@ -52,7 +54,7 @@ impl WidgetRef for &TrustDirectoryWidget {
),
Span::raw(self.cwd.to_string_lossy().to_string()),
]),
"".into(),
Line::from(""),
];
if self.is_git_repo {
@@ -63,12 +65,12 @@ impl WidgetRef for &TrustDirectoryWidget {
" to work in this folder without asking for approval.",
));
} else {
lines.push(
" Since this folder is not version controlled, we recommend requiring".into(),
);
lines.push(" approval of all edits and commands.".into());
lines.push(Line::from(
" Since this folder is not version controlled, we recommend requiring",
));
lines.push(Line::from(" approval of all edits and commands."));
}
lines.push("".into());
lines.push(Line::from(""));
let create_option =
|idx: usize, option: TrustDirectorySelection, text: &str| -> Line<'static> {
@@ -111,7 +113,7 @@ impl WidgetRef for &TrustDirectoryWidget {
"Require approval of edits and commands",
));
}
lines.push("".into());
lines.push(Line::from(""));
if let Some(error) = &self.error {
lines.push(Line::from(format!(" {error}")).fg(crate::colors::error()));
lines.push(Line::from(""));
+3 -1
View File
@@ -1,8 +1,10 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
use ratatui::style::Stylize;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::WidgetRef;
use crate::onboarding::onboarding_screen::StepStateProvider;
+5 -5
View File
@@ -143,7 +143,7 @@ impl PagerView {
.dim()
.render_ref(area, buf);
let header = format!("/ {}", self.title);
header.dim().render_ref(area, buf);
Span::from(header).dim().render_ref(area, buf);
}
// Removed unused render_content_page (replaced by render_content_page_prepared)
@@ -543,7 +543,7 @@ mod tests {
fn static_overlay_snapshot_basic() {
// Prepare a static overlay with a few lines and a title
let mut overlay = StaticOverlay::with_title(
vec!["one".into(), "two".into(), "three".into()],
vec![Line::from("one"), Line::from("two"), Line::from("three")],
"S T A T I C".to_string(),
);
let mut term = Terminal::new(TestBackend::new(40, 10)).expect("term");
@@ -555,7 +555,7 @@ mod tests {
#[test]
fn pager_wrap_cache_reuses_for_same_width_and_rebuilds_on_change() {
let long = "This is a long line that should wrap multiple times to ensure non-empty wrapped output.";
let mut pv = PagerView::new(vec![long.into(), long.into()], "T".to_string(), 0);
let mut pv = PagerView::new(vec![Line::from(long), Line::from(long)], "T".to_string(), 0);
// Build cache at width 24
pv.ensure_wrapped(24);
@@ -584,13 +584,13 @@ mod tests {
#[test]
fn pager_wrap_cache_invalidates_on_append() {
let long = "Another long line for wrapping behavior verification.";
let mut pv = PagerView::new(vec![long.into()], "T".to_string(), 0);
let mut pv = PagerView::new(vec![Line::from(long)], "T".to_string(), 0);
pv.ensure_wrapped(28);
let (w1, _) = pv.cached();
let len1 = w1.len();
// Append new lines should cause ensure_wrapped to rebuild due to len change
pv.lines.extend([long.into(), long.into()]);
pv.lines.extend([Line::from(long), Line::from(long)]);
pv.ensure_wrapped(28);
let (w2, _) = pv.cached();
assert!(
@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"• Proposed Change README.md (+2 -0) "
"proposed patch to 1 file (+2 -0) "
" └ README.md "
" 1 +first line "
" 2 +second line "
" "
@@ -12,4 +12,3 @@ expression: terminal.backend()
" "
" "
" "
" "
@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"• Proposed Change example.txt (+1 -1) "
"proposed patch to 1 file (+1 -1) "
" └ example.txt "
" 1 "
" 2 -Y "
" 2 +Y changed "
@@ -12,4 +12,3 @@ expression: terminal.backend()
" "
" "
" "
" "
@@ -1,13 +1,12 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"• Proposed Change README.md (+1 -1) "
"proposed patch to 1 file (+1 -1) "
" └ README.md "
" 1 -# Codex CLI (Rust Implementation) "
" 1 +# Codex CLI (Rust Implementation) banana "
" "
" "
" "
" "
" "
@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"• Proposed Change src/lib.rs → src/lib_new.rs (+1 -1) "
"proposed patch to 1 file (+1 -1) "
" └ src/lib.rs → src/lib_new.rs "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
@@ -14,4 +14,3 @@ expression: terminal.backend()
" "
" "
" "
" "
@@ -1,9 +1,9 @@
---
source: tui/src/diff_render.rs
assertion_line: 765
expression: terminal.backend()
---
"• Proposed Change example.txt (+2 -2) "
"proposed patch to 1 file (+2 -2) "
" └ example.txt "
" 1 line 1 "
" 2 -line 2 "
" 2 +line two changed "
@@ -18,4 +18,3 @@ expression: terminal.backend()
" 9 +line nine changed "
" 10 line 10 "
" "
" "
+2 -3
View File
@@ -619,9 +619,8 @@ mod tests {
}
}
impl HistorySink for TestSink {
fn insert_history_cell(&self, cell: Box<dyn crate::history_cell::HistoryCell>) {
// For tests, store the transcript representation of the cell.
self.lines.borrow_mut().push(cell.transcript_lines());
fn insert_history(&self, lines: Vec<Line<'static>>) {
self.lines.borrow_mut().push(lines);
}
fn start_commit_animation(&self) {}
fn stop_commit_animation(&self) {}
+9 -15
View File
@@ -29,7 +29,6 @@ use ratatui::widgets::Wrap;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
/// Request coming from the agent that needs user approval.
pub(crate) enum ApprovalRequest {
@@ -239,8 +238,8 @@ impl UserApprovalWidget<'_> {
match &self.approval_request {
ApprovalRequest::Exec { command, .. } => {
let cmd = strip_bash_lc_and_escape(command);
// TODO: move this rendering into history_cell.
let mut lines: Vec<Line<'static>> = vec![];
let mut cmd_span: Span = cmd.clone().into();
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
// Result line based on decision.
match decision {
@@ -285,20 +284,15 @@ impl UserApprovalWidget<'_> {
]));
}
}
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
history_cell::new_user_approval_decision(lines),
)));
}
ApprovalRequest::ApplyPatch { .. } => {
// No history line for patch approval decisions.
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
}
}
if !feedback.trim().is_empty() {
lines.push(Line::from("feedback:"));
for l in feedback.lines() {
lines.push(Line::from(l.to_string()));
}
}
lines.push(Line::from(""));