mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +00:00
revert(tui): keep forked TUI; defer upstream integration
This commit is contained in:
Generated
+1
-10
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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..]);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -236,6 +236,7 @@ impl TextArea {
|
||||
} => self.delete_backward_word(),
|
||||
KeyEvent {
|
||||
code: KeyCode::Backspace,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
|
||||
-1
@@ -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
-2
@@ -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. "
|
||||
|
||||
+2
-1
@@ -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
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 779
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
+2
-1
@@ -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
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 807
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
+2
-1
@@ -2,4 +2,5 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: exec_blob
|
||||
---
|
||||
• Ran sleep 1
|
||||
>_
|
||||
✗ ⌨️ sleep 1
|
||||
|
||||
+1
@@ -1,5 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 878
|
||||
expression: terminal.backend()
|
||||
---
|
||||
" "
|
||||
|
||||
+1
-2
@@ -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 "
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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."),
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-3
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-3
@@ -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()
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
+2
-3
@@ -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 "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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(""));
|
||||
|
||||
Reference in New Issue
Block a user