Compare commits

..

3 Commits

Author SHA1 Message Date
Ahmed Ibrahim
1ac36e67af fix plan mismatch 2025-10-01 14:36:08 -07:00
Ahmed Ibrahim
11c28a319b fix plan mismatch 2025-10-01 14:32:56 -07:00
Ahmed Ibrahim
7b5b4e08a0 fix plan mismatch 2025-10-01 14:19:37 -07:00
65 changed files with 1366 additions and 1807 deletions

10
codex-rs/Cargo.lock generated
View File

@@ -863,7 +863,6 @@ dependencies = [
"codex-rmcp-client",
"core_test_support",
"dirs",
"dunce",
"env-flags",
"escargot",
"eventsource-stream",
@@ -1201,7 +1200,6 @@ dependencies = [
"crossterm",
"diffy",
"dirs",
"dunce",
"image",
"insta",
"itertools 0.14.0",
@@ -1800,12 +1798,6 @@ version = "0.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5"
[[package]]
name = "dunce"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "dupe"
version = "0.9.1"
@@ -4072,7 +4064,7 @@ dependencies = [
"once_cell",
"socket2 0.5.10",
"tracing",
"windows-sys 0.59.0",
"windows-sys 0.52.0",
]
[[package]]

View File

@@ -97,7 +97,6 @@ derive_more = "2"
diffy = "0.4.2"
dirs = "6"
dotenvy = "0.15.7"
dunce = "1.0.4"
env-flags = "0.1.1"
env_logger = "0.11.5"
escargot = "0.5"

View File

@@ -11,7 +11,6 @@ pub const JSONRPC_VERSION: &str = "2.0";
#[serde(untagged)]
pub enum RequestId {
String(String),
#[ts(type = "number")]
Integer(i64),
}

View File

@@ -839,9 +839,6 @@ pub async fn run_main(_cli: Cli, _codex_linux_sandbox_exe: Option<PathBuf>) -> a
&& matches!(key.code, KeyCode::Char('n') | KeyCode::Char('N'))
|| matches!(key.code, KeyCode::Char('\u{000E}'));
if is_ctrl_n {
if app.new_task.is_none() {
continue;
}
if app.best_of_modal.is_some() {
app.best_of_modal = None;
needs_redraw = true;

View File

@@ -262,9 +262,9 @@ fn draw_footer(frame: &mut Frame, area: Rect, app: &mut App) {
help.push(": Apply ".dim());
}
help.push("o : Set Env ".dim());
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
if app.new_task.is_some() {
help.push("Ctrl+N".dim());
help.push(format!(": Attempts {}x ", app.best_of_n).dim());
help.push("(editing new task) ".dim());
} else {
help.push("n : New Task ".dim());
@@ -1004,40 +1004,32 @@ pub fn draw_best_of_modal(frame: &mut Frame, area: Rect, app: &mut App) {
use ratatui::widgets::Wrap;
let inner = overlay_outer(area);
const MAX_WIDTH: u16 = 40;
const MIN_WIDTH: u16 = 20;
const MAX_HEIGHT: u16 = 12;
const MIN_HEIGHT: u16 = 6;
let modal_width = inner.width.min(MAX_WIDTH).max(inner.width.min(MIN_WIDTH));
let modal_height = inner
.height
.min(MAX_HEIGHT)
.max(inner.height.min(MIN_HEIGHT));
let modal_x = inner.x + (inner.width.saturating_sub(modal_width)) / 2;
let modal_y = inner.y + (inner.height.saturating_sub(modal_height)) / 2;
let modal_area = Rect::new(modal_x, modal_y, modal_width, modal_height);
let title = Line::from(vec!["Parallel Attempts".magenta().bold()]);
let block = overlay_block().title(title);
frame.render_widget(Clear, modal_area);
frame.render_widget(block.clone(), modal_area);
let content = overlay_content(modal_area);
frame.render_widget(Clear, inner);
frame.render_widget(block.clone(), inner);
let content = overlay_content(inner);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(2), Constraint::Min(1)])
.split(content);
let hint = Paragraph::new(Line::from("Use ↑/↓ to choose, 1-4 jump".cyan().dim()))
.wrap(Wrap { trim: true });
let hint = Paragraph::new(Line::from(
"Use ↑/↓ to choose, 1-4 jump; Enter confirm, Esc cancel"
.cyan()
.dim(),
))
.wrap(Wrap { trim: true });
frame.render_widget(hint, rows[0]);
let selected = app.best_of_modal.as_ref().map(|m| m.selected).unwrap_or(0);
let options = [1usize, 2, 3, 4];
let mut items: Vec<ListItem> = Vec::new();
for &attempts in &options {
let noun = if attempts == 1 { "attempt" } else { "attempts" };
let mut spans: Vec<ratatui::text::Span> = vec![format!("{attempts} {noun:<8}").into()];
let mut spans: Vec<ratatui::text::Span> =
vec![format!("{attempts} attempt{}", if attempts == 1 { "" } else { "s" }).into()];
spans.push(" ".into());
spans.push(format!("{attempts}x parallel").dim());
if attempts == app.best_of_n {

View File

@@ -27,7 +27,6 @@ codex-protocol = { workspace = true }
codex-app-server-protocol = { workspace = true }
codex-otel = { workspace = true, features = ["otel"] }
dirs = { workspace = true }
dunce = { workspace = true }
env-flags = { workspace = true }
eventsource-stream = { workspace = true }
futures = { workspace = true }

View File

@@ -5,6 +5,7 @@ You are Codex, based on GPT-5. You are running as a coding agent in the Codex CL
- The arguments to `shell` will be passed to execvp(). Most terminal commands should be prefixed with ["bash", "-lc"].
- Always set the `workdir` param when using the shell function. Do not use `cd` unless absolutely necessary.
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
- When editing or creating files, you MUST use apply_patch as a standalone tool without going through ["bash", "-lc"], `Python`, `cat`, `sed`, ... Example: functions.shell({"command":["apply_patch","*** Begin Patch\nAdd File: hello.txt\n+Hello, world!\n*** End Patch"]}).
## Editing constraints

View File

@@ -6,8 +6,6 @@ use crate::client_common::ResponseEvent;
use crate::client_common::ResponseStream;
use crate::error::CodexErr;
use crate::error::Result;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use crate::model_family::ModelFamily;
use crate::openai_tools::create_tools_json_for_chat_completions_api;
use crate::util::backoff;
@@ -322,18 +320,11 @@ pub(crate) async fn stream_chat_completions(
let status = res.status();
if !(status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error()) {
let body = (res.text().await).unwrap_or_default();
return Err(CodexErr::UnexpectedStatus(UnexpectedResponseError {
status,
body,
request_id: None,
}));
return Err(CodexErr::UnexpectedStatus(status, body));
}
if attempt > max_retries {
return Err(CodexErr::RetryLimit(RetryLimitReachedError {
status,
request_id: None,
}));
return Err(CodexErr::RetryLimit(status));
}
let retry_after_secs = res

View File

@@ -5,8 +5,6 @@ use std::time::Duration;
use crate::AuthManager;
use crate::auth::CodexAuth;
use crate::error::RetryLimitReachedError;
use crate::error::UnexpectedResponseError;
use bytes::Bytes;
use codex_app_server_protocol::AuthMode;
use codex_protocol::ConversationId;
@@ -309,17 +307,14 @@ impl ModelClient {
.log_request(attempt, || req_builder.send())
.await;
let mut request_id = None;
if let Ok(resp) = &res {
request_id = resp
.headers()
.get("cf-ray")
.map(|v| v.to_str().unwrap_or_default().to_string());
trace!(
"Response status: {}, cf-ray: {:?}",
"Response status: {}, cf-ray: {}",
resp.status(),
request_id
resp.headers()
.get("cf-ray")
.map(|v| v.to_str().unwrap_or_default())
.unwrap_or_default()
);
}
@@ -379,11 +374,7 @@ impl ModelClient {
// Surface the error body to callers. Use `unwrap_or_default` per Clippy.
let body = res.text().await.unwrap_or_default();
return Err(StreamAttemptError::Fatal(CodexErr::UnexpectedStatus(
UnexpectedResponseError {
status,
body,
request_id: None,
},
status, body,
)));
}
@@ -392,6 +383,25 @@ impl ModelClient {
let body = res.json::<ErrorResponse>().await.ok();
if let Some(ErrorResponse { error }) = body {
if error.r#type.as_deref() == Some("usage_limit_reached") {
let context = "usage_limit_reached";
if Self::refresh_on_plan_mismatch(
auth_manager,
&auth,
error.plan_type.clone(),
context,
)
.await
{
return Err(StreamAttemptError::RetryableTransportError(
CodexErr::Stream(
format!(
"plan mismatch detected during {context}; retrying"
),
None,
),
));
}
// Prefer the plan_type provided in the error message if present
// because it's more up to date than the one encoded in the auth
// token.
@@ -406,6 +416,24 @@ impl ModelClient {
});
return Err(StreamAttemptError::Fatal(codex_err));
} else if error.r#type.as_deref() == Some("usage_not_included") {
let context = "usage_not_included";
if Self::refresh_on_plan_mismatch(
auth_manager,
&auth,
error.plan_type.clone(),
context,
)
.await
{
return Err(StreamAttemptError::RetryableTransportError(
CodexErr::Stream(
format!(
"plan mismatch detected during {context}; retrying"
),
None,
),
));
}
return Err(StreamAttemptError::Fatal(CodexErr::UsageNotIncluded));
}
}
@@ -414,13 +442,54 @@ impl ModelClient {
Err(StreamAttemptError::RetryableHttpError {
status,
retry_after,
request_id,
})
}
Err(e) => Err(StreamAttemptError::RetryableTransportError(e.into())),
}
}
async fn refresh_on_plan_mismatch(
auth_manager: &Option<Arc<AuthManager>>,
auth: &Option<CodexAuth>,
server_plan_type: Option<PlanType>,
log_context: &str,
) -> bool {
let Some(server_plan) = server_plan_type else {
return false;
};
let jwt_plan = auth.as_ref().and_then(CodexAuth::get_plan_type);
if jwt_plan == Some(server_plan.clone()) {
return false;
}
if let Some(manager) = auth_manager.as_ref() {
match manager.refresh_token().await {
Ok(_) => {
warn!(
context = log_context,
?server_plan,
previous_plan = ?jwt_plan,
"plan mismatch detected; refreshed token and will retry"
);
return true;
}
Err(err) => {
warn!(
context = log_context,
?server_plan,
previous_plan = ?jwt_plan,
error = ?err,
"failed to refresh token after plan mismatch"
);
}
}
}
false
}
pub fn get_provider(&self) -> ModelProviderInfo {
self.provider.clone()
}
@@ -458,7 +527,6 @@ enum StreamAttemptError {
RetryableHttpError {
status: StatusCode,
retry_after: Option<Duration>,
request_id: Option<String>,
},
RetryableTransportError(CodexErr),
Fatal(CodexErr),
@@ -483,13 +551,11 @@ impl StreamAttemptError {
fn into_error(self) -> CodexErr {
match self {
Self::RetryableHttpError {
status, request_id, ..
} => {
Self::RetryableHttpError { status, .. } => {
if status == StatusCode::INTERNAL_SERVER_ERROR {
CodexErr::InternalServerError
} else {
CodexErr::RetryLimit(RetryLimitReachedError { status, request_id })
CodexErr::RetryLimit(status)
}
}
Self::RetryableTransportError(error) => error,
@@ -1339,4 +1405,32 @@ mod tests {
let plan_json = serde_json::to_string(&resp.error.plan_type).expect("serialize plan_type");
assert_eq!(plan_json, "\"vip\"");
}
#[tokio::test]
async fn refresh_on_plan_mismatch_retries_when_plan_differs() {
use crate::token_data::KnownPlan;
use crate::token_data::PlanType;
use std::path::PathBuf;
use std::sync::Arc;
let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing();
if let Some(auth_json) = auth.auth_dot_json.lock().unwrap().as_mut()
&& let Some(tokens) = auth_json.tokens.as_mut()
{
tokens.id_token.chatgpt_plan_type = Some(PlanType::Known(KnownPlan::Plus));
tokens.id_token.raw_jwt = "dummy".to_string();
}
let manager = Arc::new(AuthManager::new(PathBuf::new()));
let should_retry = ModelClient::refresh_on_plan_mismatch(
&Some(manager),
&Some(auth),
Some(PlanType::Known(KnownPlan::Team)),
"usage_limit_reached",
)
.await;
assert!(should_retry);
}
}

View File

@@ -141,9 +141,6 @@ pub struct Config {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: usize,
/// Additional filenames to try when looking for project-level docs.
pub project_doc_fallback_filenames: Vec<String>,
/// Directory containing all Codex state (defaults to `~/.codex` but can be
/// overridden by the `CODEX_HOME` environment variable).
pub codex_home: PathBuf,
@@ -673,9 +670,6 @@ pub struct ConfigToml {
/// Maximum number of bytes to include from an AGENTS.md project doc file.
pub project_doc_max_bytes: Option<usize>,
/// Ordered list of fallback filenames to look for when AGENTS.md is missing.
pub project_doc_fallback_filenames: Option<Vec<String>>,
/// Profile to use from the `profiles` map.
pub profile: Option<String>,
@@ -1044,19 +1038,6 @@ impl Config {
mcp_servers: cfg.mcp_servers,
model_providers,
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
project_doc_fallback_filenames: cfg
.project_doc_fallback_filenames
.unwrap_or_default()
.into_iter()
.filter_map(|name| {
let trimmed = name.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.collect(),
codex_home,
history,
file_opener: cfg.file_opener.unwrap_or(UriBasedFileOpener::VsCode),
@@ -1079,7 +1060,7 @@ impl Config {
.chatgpt_base_url
.or(cfg.chatgpt_base_url)
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
include_plan_tool: include_plan_tool.unwrap_or(true),
include_plan_tool: include_plan_tool.unwrap_or(false),
include_apply_patch_tool: include_apply_patch_tool.unwrap_or(false),
tools_web_search_request,
use_experimental_streamable_shell_tool: cfg
@@ -1830,7 +1811,6 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -1842,7 +1822,7 @@ model_verbosity = "high"
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: true,
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,
@@ -1891,7 +1871,6 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -1903,7 +1882,7 @@ model_verbosity = "high"
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: true,
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,
@@ -1967,7 +1946,6 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -1979,7 +1957,7 @@ model_verbosity = "high"
model_verbosity: None,
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: true,
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,
@@ -2029,7 +2007,6 @@ model_verbosity = "high"
mcp_servers: HashMap::new(),
model_providers: fixture.model_provider_map.clone(),
project_doc_max_bytes: PROJECT_DOC_MAX_BYTES,
project_doc_fallback_filenames: Vec::new(),
codex_home: fixture.codex_home(),
history: History::default(),
file_opener: UriBasedFileOpener::VsCode,
@@ -2041,7 +2018,7 @@ model_verbosity = "high"
model_verbosity: Some(Verbosity::High),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
include_plan_tool: true,
include_plan_tool: false,
include_apply_patch_tool: false,
tools_web_search_request: false,
use_experimental_streamable_shell_tool: false,

View File

@@ -76,8 +76,8 @@ pub enum CodexErr {
Interrupted,
/// Unexpected HTTP status code.
#[error("{0}")]
UnexpectedStatus(UnexpectedResponseError),
#[error("unexpected status {0}: {1}")]
UnexpectedStatus(StatusCode, String),
#[error("{0}")]
UsageLimitReached(UsageLimitReachedError),
@@ -91,8 +91,8 @@ pub enum CodexErr {
InternalServerError,
/// Retry limit exceeded.
#[error("{0}")]
RetryLimit(RetryLimitReachedError),
#[error("exceeded retry limit, last status: {0}")]
RetryLimit(StatusCode),
/// Agent loop died unexpectedly
#[error("internal error; agent loop died unexpectedly")]
@@ -135,49 +135,6 @@ pub enum CodexErr {
EnvVar(EnvVarError),
}
#[derive(Debug)]
pub struct UnexpectedResponseError {
pub status: StatusCode,
pub body: String,
pub request_id: Option<String>,
}
impl std::fmt::Display for UnexpectedResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"unexpected status {}: {}{}",
self.status,
self.body,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
impl std::error::Error for UnexpectedResponseError {}
#[derive(Debug)]
pub struct RetryLimitReachedError {
pub status: StatusCode,
pub request_id: Option<String>,
}
impl std::fmt::Display for RetryLimitReachedError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"exceeded retry limit, last status: {}{}",
self.status,
self.request_id
.as_ref()
.map(|id| format!(", request id: {id}"))
.unwrap_or_default()
)
}
}
#[derive(Debug)]
pub struct UsageLimitReachedError {
pub(crate) plan_type: Option<PlanType>,

View File

@@ -1,7 +1,6 @@
//! Project-level documentation discovery.
//!
//! Project-level documentation is primarily stored in files named `AGENTS.md`.
//! Additional fallback filenames can be configured via `project_doc_fallback_filenames`.
//! Project-level documentation can be stored in files named `AGENTS.md`.
//! We include the concatenation of all files found along the path from the
//! repository root to the current working directory as follows:
//!
@@ -14,13 +13,12 @@
//! 3. We do **not** walk past the Git root.
use crate::config::Config;
use dunce::canonicalize as normalize_path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use tracing::error;
/// Default filename scanned for project-level docs.
pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md";
/// Currently, we only match the filename `AGENTS.md` exactly.
const CANDIDATE_FILENAMES: &[&str] = &["AGENTS.md"];
/// When both `Config::instructions` and the project doc are present, they will
/// be concatenated with the following separator.
@@ -110,7 +108,7 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
/// is zero, returns an empty list.
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
let mut dir = config.cwd.clone();
if let Ok(canon) = normalize_path(&dir) {
if let Ok(canon) = dir.canonicalize() {
dir = canon;
}
@@ -154,9 +152,8 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
};
let mut found: Vec<PathBuf> = Vec::new();
let candidate_filenames = candidate_filenames(config);
for d in search_dirs {
for name in &candidate_filenames {
for name in CANDIDATE_FILENAMES {
let candidate = d.join(name);
match std::fs::symlink_metadata(&candidate) {
Ok(md) => {
@@ -176,22 +173,6 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
Ok(found)
}
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
let mut names: Vec<&'a str> =
Vec::with_capacity(1 + config.project_doc_fallback_filenames.len());
names.push(DEFAULT_PROJECT_DOC_FILENAME);
for candidate in &config.project_doc_fallback_filenames {
let candidate = candidate.as_str();
if candidate.is_empty() {
continue;
}
if !names.contains(&candidate) {
names.push(candidate);
}
}
names
}
#[cfg(test)]
mod tests {
use super::*;
@@ -221,20 +202,6 @@ mod tests {
config
}
fn make_config_with_fallback(
root: &TempDir,
limit: usize,
instructions: Option<&str>,
fallbacks: &[&str],
) -> Config {
let mut config = make_config(root, limit, instructions);
config.project_doc_fallback_filenames = fallbacks
.iter()
.map(std::string::ToString::to_string)
.collect();
config
}
/// AGENTS.md missing should yield `None`.
#[tokio::test]
async fn no_doc_file_returns_none() {
@@ -380,45 +347,4 @@ mod tests {
let res = get_user_instructions(&cfg).await.expect("doc expected");
assert_eq!(res, "root doc\n\ncrate doc");
}
/// When AGENTS.md is absent but a configured fallback exists, the fallback is used.
#[tokio::test]
async fn uses_configured_fallback_when_agents_missing() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("EXAMPLE.md"), "example instructions").unwrap();
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md"]);
let res = get_user_instructions(&cfg)
.await
.expect("fallback doc expected");
assert_eq!(res, "example instructions");
}
/// AGENTS.md remains preferred when both AGENTS.md and fallbacks are present.
#[tokio::test]
async fn agents_md_preferred_over_fallbacks() {
let tmp = tempfile::tempdir().expect("tempdir");
fs::write(tmp.path().join("AGENTS.md"), "primary").unwrap();
fs::write(tmp.path().join("EXAMPLE.md"), "secondary").unwrap();
let cfg = make_config_with_fallback(&tmp, 4096, None, &["EXAMPLE.md", ".example.md"]);
let res = get_user_instructions(&cfg)
.await
.expect("AGENTS.md should win");
assert_eq!(res, "primary");
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
assert_eq!(discovery.len(), 1);
assert!(
discovery[0]
.file_name()
.unwrap()
.to_string_lossy()
.eq(DEFAULT_PROJECT_DOC_FILENAME)
);
}
}

View File

@@ -2,45 +2,34 @@ use serde::Deserialize;
use serde::Serialize;
use ts_rs::TS;
/// Top-level JSONL events emitted by codex exec
/// Top-level events emitted on the Codex Exec thread stream.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "type")]
pub enum ThreadEvent {
/// Emitted when a new thread is started as the first event.
#[serde(rename = "thread.started")]
ThreadStarted(ThreadStartedEvent),
/// Emitted when a turn is started by sending a new prompt to the model.
/// A turn encompasses all events that happen while agent is processing the prompt.
#[serde(rename = "turn.started")]
TurnStarted(TurnStartedEvent),
/// Emitted when a turn is completed. Typically right after the assistant's response.
#[serde(rename = "turn.completed")]
TurnCompleted(TurnCompletedEvent),
/// Indicates that a turn failed with an error.
#[serde(rename = "turn.failed")]
TurnFailed(TurnFailedEvent),
/// Emitted when a new item is added to the thread. Typically the item will be in an "in progress" state.
#[serde(rename = "item.started")]
ItemStarted(ItemStartedEvent),
/// Emitted when an item is updated.
#[serde(rename = "item.updated")]
ItemUpdated(ItemUpdatedEvent),
/// Signals that an item has reached a terminal state—either success or failure.
#[serde(rename = "item.completed")]
ItemCompleted(ItemCompletedEvent),
/// Represents an unrecoverable error emitted directly by the event stream.
#[serde(rename = "error")]
Error(ThreadErrorEvent),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ThreadStartedEvent {
/// The identified of the new thread. Can be used to resume the thread later.
pub thread_id: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct TurnStartedEvent {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
@@ -53,14 +42,11 @@ pub struct TurnFailedEvent {
pub error: ThreadErrorEvent,
}
/// Describes the usage of tokens during a turn.
/// Minimal usage summary for a turn.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS, Default)]
pub struct Usage {
/// The number of input tokens used during the turn.
pub input_tokens: u64,
/// The number of cached input tokens used during the turn.
pub cached_input_tokens: u64,
/// The number of output tokens used during the turn.
pub output_tokens: u64,
}
@@ -97,44 +83,34 @@ pub struct ThreadItem {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(tag = "item_type", rename_all = "snake_case")]
pub enum ThreadItemDetails {
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
AssistantMessage(AssistantMessageItem),
/// Agent's reasoning summary.
Reasoning(ReasoningItem),
/// Tracks a command executed by the agent. The item starts when the command is
/// spawned, and completes when the process exits with an exit code.
CommandExecution(CommandExecutionItem),
/// Represents a set of file changes by the agent. The item is emitted only as a
/// completed event once the patch succeeds or fails.
FileChange(FileChangeItem),
/// Represents a call to an MCP tool. The item starts when the invocation is
/// dispatched and completes when the MCP server reports success or failure.
McpToolCall(McpToolCallItem),
/// Captures a web search request. It starts when the search is kicked off
/// and completes when results are returned to the agent.
WebSearch(WebSearchItem),
/// Tracks the agent's running to-do list. It starts when the plan is first
/// issued, updates as steps change state, and completes when the turn ends.
TodoList(TodoListItem),
/// Describes a non-fatal error surfaced as an item.
Error(ErrorItem),
}
/// Response from the agent.
/// Either a natural-language response or a JSON string when structured output is requested.
/// Session metadata.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct SessionItem {
pub session_id: String,
}
/// Assistant message payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct AssistantMessageItem {
pub text: String,
}
/// Agent's reasoning summary.
/// Model reasoning summary payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ReasoningItem {
pub text: String,
}
/// The status of a command execution.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum CommandExecutionStatus {
@@ -144,7 +120,7 @@ pub enum CommandExecutionStatus {
Failed,
}
/// A command executed by the agent.
/// Local shell command execution payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct CommandExecutionItem {
pub command: String,
@@ -154,14 +130,13 @@ pub struct CommandExecutionItem {
pub status: CommandExecutionStatus,
}
/// A set of file changes by the agent.
/// Single file change summary for a patch.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileUpdateChange {
pub path: String,
pub kind: PatchChangeKind,
}
/// The status of a file change.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchApplyStatus {
@@ -169,14 +144,14 @@ pub enum PatchApplyStatus {
Failed,
}
/// A set of file changes by the agent.
/// Patch application payload.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct FileChangeItem {
pub changes: Vec<FileUpdateChange>,
pub status: PatchApplyStatus,
}
/// Indicates the type of the file change.
/// Known change kinds for a patch.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
#[serde(rename_all = "snake_case")]
pub enum PatchChangeKind {
@@ -185,7 +160,6 @@ pub enum PatchChangeKind {
Update,
}
/// The status of an MCP tool call.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default, TS)]
#[serde(rename_all = "snake_case")]
pub enum McpToolCallStatus {
@@ -195,7 +169,6 @@ pub enum McpToolCallStatus {
Failed,
}
/// A call to an MCP tool.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct McpToolCallItem {
pub server: String,
@@ -203,19 +176,16 @@ pub struct McpToolCallItem {
pub status: McpToolCallStatus,
}
/// A web search request.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct WebSearchItem {
pub query: String,
}
/// An error notification.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct ErrorItem {
pub message: String,
}
/// An item in agent's to-do list.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, TS)]
pub struct TodoItem {
pub text: String,

View File

@@ -25,7 +25,6 @@ use tiny_http::Header;
use tiny_http::Request;
use tiny_http::Response;
use tiny_http::Server;
use tiny_http::StatusCode;
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
const DEFAULT_PORT: u16 = 1455;
@@ -149,15 +148,8 @@ pub fn run_login_server(opts: ServerOptions) -> io::Result<LoginServer> {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
None
}
HandledRequest::ResponseAndExit {
headers,
body,
result,
} => {
let _ = tokio::task::spawn_blocking(move || {
send_response_with_disconnect(req, headers, body)
})
.await;
HandledRequest::ResponseAndExit { response, result } => {
let _ = tokio::task::spawn_blocking(move || req.respond(response)).await;
Some(result)
}
HandledRequest::RedirectWithHeader(header) => {
@@ -193,8 +185,7 @@ enum HandledRequest {
Response(Response<Cursor<Vec<u8>>>),
RedirectWithHeader(Header),
ResponseAndExit {
headers: Vec<Header>,
body: Vec<u8>,
response: Response<Cursor<Vec<u8>>>,
result: io::Result<()>,
},
}
@@ -284,21 +275,20 @@ async fn process_request(
}
"/success" => {
let body = include_str!("assets/success.html");
let mut resp = Response::from_data(body.as_bytes());
if let Ok(h) = tiny_http::Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
) {
resp.add_header(h);
}
HandledRequest::ResponseAndExit {
headers: match Header::from_bytes(
&b"Content-Type"[..],
&b"text/html; charset=utf-8"[..],
) {
Ok(header) => vec![header],
Err(_) => Vec::new(),
},
body: body.as_bytes().to_vec(),
response: resp,
result: Ok(()),
}
}
"/cancel" => HandledRequest::ResponseAndExit {
headers: Vec::new(),
body: b"Login cancelled".to_vec(),
response: Response::from_string("Login cancelled"),
result: Err(io::Error::new(
io::ErrorKind::Interrupted,
"Login cancelled",
@@ -308,50 +298,6 @@ async fn process_request(
}
}
/// tiny_http filters `Connection` headers out of `Response` objects, so using
/// `req.respond` never informs the client (or the library) that a keep-alive
/// socket should be closed. That leaves the per-connection worker parked in a
/// loop waiting for more requests, which in turn causes the next login attempt
/// to hang on the old connection. This helper bypasses tiny_https response
/// machinery: it extracts the raw writer, prints the HTTP response manually,
/// and always appends `Connection: close`, ensuring the socket is closed from
/// the server side. Ideally, tiny_http would provide an API to control
/// server-side connection persistence, but it does not.
fn send_response_with_disconnect(
req: Request,
mut headers: Vec<Header>,
body: Vec<u8>,
) -> io::Result<()> {
let status = StatusCode(200);
let mut writer = req.into_writer();
let reason = status.default_reason_phrase();
write!(writer, "HTTP/1.1 {} {}\r\n", status.0, reason)?;
headers.retain(|h| !h.field.equiv("Connection"));
if let Ok(close_header) = Header::from_bytes(&b"Connection"[..], &b"close"[..]) {
headers.push(close_header);
}
let content_length_value = format!("{}", body.len());
if let Ok(content_length_header) =
Header::from_bytes(&b"Content-Length"[..], content_length_value.as_bytes())
{
headers.push(content_length_header);
}
for header in headers {
write!(
writer,
"{}: {}\r\n",
header.field.as_str(),
header.value.as_str()
)?;
}
writer.write_all(b"\r\n")?;
writer.write_all(&body)?;
writer.flush()
}
fn build_authorize_url(
issuer: &str,
client_id: &str,

View File

@@ -22,7 +22,7 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
help="Print the version that would be used and exit before making changes.",
)
group = parser.add_mutually_exclusive_group()
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--publish-alpha",
action="store_true",
@@ -33,30 +33,13 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
action="store_true",
help="Publish the next stable release by bumping the minor version.",
)
parser.add_argument(
"--emergency-version-override",
help="Publish a specific version because tag was created for the previous release but it never succeeded. Value should be semver, e.g., `0.43.0-alpha.9`.",
)
args = parser.parse_args(argv[1:])
if not (
args.publish_alpha
or args.publish_release
or args.emergency_version_override
):
parser.error(
"Must specify --publish-alpha, --publish-release, or --emergency-version-override."
)
return args
return parser.parse_args(argv[1:])
def main(argv: list[str]) -> int:
args = parse_args(argv)
try:
if args.emergency_version_override:
version = args.emergency_version_override
else:
version = determine_version(args)
version = determine_version(args)
print(f"Publishing version {version}")
if args.dry_run:
return 0

View File

@@ -44,7 +44,6 @@ color-eyre = { workspace = true }
crossterm = { workspace = true, features = ["bracketed-paste", "event-stream"] }
diffy = { workspace = true }
dirs = { workspace = true }
dunce = { workspace = true }
image = { workspace = true, features = ["jpeg", "png"] }
itertools = { workspace = true }
lazy_static = { workspace = true }

View File

@@ -1,14 +1,10 @@
use crate::app_backtrack::BacktrackState;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::ApprovalRequest;
use crate::chatwidget::ChatWidget;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::file_search::FileSearchManager;
use crate::history_cell::HistoryCell;
use crate::pager_overlay::Overlay;
use crate::render::highlight::highlight_bash_to_lines;
use crate::resume_picker::ResumeSelection;
use crate::tui;
use crate::tui::TuiEvent;
@@ -296,7 +292,7 @@ impl App {
} else {
text.lines().map(ansi_escape_line).collect()
};
self.overlay = Some(Overlay::new_static_with_lines(
self.overlay = Some(Overlay::new_static_with_title(
pager_lines,
"D I F F".to_string(),
));
@@ -328,18 +324,12 @@ impl App {
Ok(()) => {
if let Some(profile) = profile {
self.chat_widget.add_info_message(
format!("Model changed to {model}{reasoning_effort} for {profile} profile", reasoning_effort = effort.map(|e| format!(" {e}")).unwrap_or_default()),
format!("Model changed to {model} for {profile} profile"),
None,
);
} else {
self.chat_widget.add_info_message(
format!(
"Model changed to {model}{reasoning_effort}",
reasoning_effort =
effort.map(|e| format!(" {e}")).unwrap_or_default()
),
None,
);
self.chat_widget
.add_info_message(format!("Model changed to {model}"), None);
}
}
Err(err) => {
@@ -373,25 +363,6 @@ impl App {
AppEvent::OpenReviewCustomPrompt => {
self.chat_widget.show_review_custom_prompt();
}
AppEvent::FullScreenApprovalRequest(request) => match request {
ApprovalRequest::ApplyPatch { cwd, changes, .. } => {
let _ = tui.enter_alt_screen();
let diff_summary = DiffSummary::new(changes, cwd);
self.overlay = Some(Overlay::new_static_with_renderables(
vec![diff_summary.into()],
"P A T C H".to_string(),
));
}
ApprovalRequest::Exec { command, .. } => {
let _ = tui.enter_alt_screen();
let full_cmd = strip_bash_lc_and_escape(&command);
let full_cmd_lines = highlight_bash_to_lines(&full_cmd);
self.overlay = Some(Overlay::new_static_with_lines(
full_cmd_lines,
"E X E C".to_string(),
));
}
},
}
Ok(true)
}

View File

@@ -4,7 +4,6 @@ use codex_core::protocol::ConversationPathResponseEvent;
use codex_core::protocol::Event;
use codex_file_search::FileMatch;
use crate::bottom_pane::ApprovalRequest;
use crate::history_cell::HistoryCell;
use codex_core::protocol::AskForApproval;
@@ -77,7 +76,4 @@ pub(crate) enum AppEvent {
/// Open the custom prompt option from the review popup.
OpenReviewCustomPrompt,
/// Open the approval popup.
FullScreenApprovalRequest(ApprovalRequest),
}

View File

@@ -1,21 +1,16 @@
use std::collections::HashMap;
use std::path::PathBuf;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::bottom_pane::BottomPaneView;
use crate::bottom_pane::CancellationEvent;
use crate::bottom_pane::list_selection_view::HeaderLine;
use crate::bottom_pane::list_selection_view::ListSelectionView;
use crate::bottom_pane::list_selection_view::SelectionItem;
use crate::bottom_pane::list_selection_view::SelectionViewParams;
use crate::diff_render::DiffSummary;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::history_cell;
use crate::render::highlight::highlight_bash_to_lines;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::text_formatting::truncate_text;
use codex_core::protocol::FileChange;
use codex_core::protocol::Op;
use codex_core::protocol::ReviewDecision;
use crossterm::event::KeyCode;
@@ -27,11 +22,8 @@ use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Wrap;
/// Request coming from the agent that needs user approval.
#[derive(Clone, Debug)]
pub(crate) enum ApprovalRequest {
Exec {
id: String,
@@ -41,15 +33,13 @@ pub(crate) enum ApprovalRequest {
ApplyPatch {
id: String,
reason: Option<String>,
cwd: PathBuf,
changes: HashMap<PathBuf, FileChange>,
grant_root: Option<PathBuf>,
},
}
/// Modal overlay asking the user to approve or deny one or more requests.
pub(crate) struct ApprovalOverlay {
current_request: Option<ApprovalRequest>,
current_variant: Option<ApprovalVariant>,
current: Option<ApprovalRequestState>,
queue: Vec<ApprovalRequest>,
app_event_tx: AppEventSender,
list: ListSelectionView,
@@ -61,16 +51,23 @@ pub(crate) struct ApprovalOverlay {
impl ApprovalOverlay {
pub fn new(request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
let mut view = Self {
current_request: None,
current_variant: None,
current: Some(ApprovalRequestState::from(request)),
queue: Vec::new(),
app_event_tx: app_event_tx.clone(),
list: ListSelectionView::new(Default::default(), app_event_tx),
list: ListSelectionView::new(
SelectionViewParams {
title: String::new(),
..Default::default()
},
app_event_tx,
),
options: Vec::new(),
current_complete: false,
done: false,
};
view.set_current(request);
let (options, params) = view.build_options();
view.options = options;
view.list = ListSelectionView::new(params, view.app_event_tx.clone());
view
}
@@ -79,30 +76,28 @@ impl ApprovalOverlay {
}
fn set_current(&mut self, request: ApprovalRequest) {
self.current_request = Some(request.clone());
let ApprovalRequestState { variant, header } = ApprovalRequestState::from(request);
self.current_variant = Some(variant.clone());
self.current = Some(ApprovalRequestState::from(request));
self.current_complete = false;
let (options, params) = Self::build_options(variant, header);
let (options, params) = self.build_options();
self.options = options;
self.list = ListSelectionView::new(params, self.app_event_tx.clone());
}
fn build_options(
variant: ApprovalVariant,
header: Box<dyn Renderable>,
) -> (Vec<ApprovalOption>, SelectionViewParams) {
let (options, title) = match &variant {
fn build_options(&self) -> (Vec<ApprovalOption>, SelectionViewParams) {
let Some(state) = self.current.as_ref() else {
return (
Vec::new(),
SelectionViewParams {
title: String::new(),
..Default::default()
},
);
};
let (options, title) = match &state.variant {
ApprovalVariant::Exec { .. } => (exec_options(), "Allow command?".to_string()),
ApprovalVariant::ApplyPatch { .. } => (patch_options(), "Apply changes?".to_string()),
};
let header = Box::new(ColumnRenderable::new([
Box::new(Line::from(title.bold())),
Box::new(Line::from("")),
header,
]));
let items = options
.iter()
.map(|opt| SelectionItem {
@@ -116,9 +111,10 @@ impl ApprovalOverlay {
.collect();
let params = SelectionViewParams {
title,
footer_hint: Some("Press Enter to confirm or Esc to cancel".to_string()),
items,
header,
header: state.header.clone(),
..Default::default()
};
@@ -132,8 +128,8 @@ impl ApprovalOverlay {
let Some(option) = self.options.get(actual_idx) else {
return;
};
if let Some(variant) = self.current_variant.as_ref() {
match (&variant, option.decision) {
if let Some(state) = self.current.as_ref() {
match (&state.variant, option.decision) {
(ApprovalVariant::Exec { id, command }, decision) => {
self.handle_exec_decision(id, command, decision);
}
@@ -175,43 +171,30 @@ impl ApprovalOverlay {
}
fn try_handle_shortcut(&mut self, key_event: &KeyEvent) -> bool {
match key_event {
KeyEvent {
kind: KeyEventKind::Press,
code: KeyCode::Char('a'),
modifiers,
..
} if modifiers.contains(KeyModifiers::CONTROL) => {
if let Some(request) = self.current_request.as_ref() {
self.app_event_tx
.send(AppEvent::FullScreenApprovalRequest(request.clone()));
true
} else {
false
}
}
KeyEvent {
kind: KeyEventKind::Press,
code: KeyCode::Char(c),
modifiers,
..
} if !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
let lower = c.to_ascii_lowercase();
match self
.options
.iter()
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
{
Some(idx) => {
self.apply_selection(idx);
true
}
None => false,
}
}
_ => false,
if key_event.kind != KeyEventKind::Press {
return false;
}
let KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} = key_event
else {
return false;
};
if modifiers.contains(KeyModifiers::CONTROL) || modifiers.contains(KeyModifiers::ALT) {
return false;
}
let lower = c.to_ascii_lowercase();
if let Some(idx) = self
.options
.iter()
.position(|opt| opt.shortcut.map(|s| s == lower).unwrap_or(false))
{
self.apply_selection(idx);
true
} else {
false
}
}
}
@@ -232,9 +215,9 @@ impl BottomPaneView for ApprovalOverlay {
return CancellationEvent::Handled;
}
if !self.current_complete
&& let Some(variant) = self.current_variant.as_ref()
&& let Some(state) = self.current.as_ref()
{
match &variant {
match &state.variant {
ApprovalVariant::Exec { id, command } => {
self.handle_exec_decision(id, command, ReviewDecision::Abort);
}
@@ -252,6 +235,14 @@ impl BottomPaneView for ApprovalOverlay {
self.done
}
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
fn try_consume_approval_request(
&mut self,
request: ApprovalRequest,
@@ -265,19 +256,9 @@ impl BottomPaneView for ApprovalOverlay {
}
}
impl Renderable for ApprovalOverlay {
fn desired_height(&self, width: u16) -> u16 {
self.list.desired_height(width)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.list.render(area, buf);
}
}
struct ApprovalRequestState {
variant: ApprovalVariant,
header: Box<dyn Renderable>,
header: Vec<HeaderLine>,
}
impl From<ApprovalRequest> for ApprovalRequestState {
@@ -288,50 +269,63 @@ impl From<ApprovalRequest> for ApprovalRequestState {
command,
reason,
} => {
let mut header: Vec<Line<'static>> = Vec::new();
let mut header = Vec::new();
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(reason.italic().into());
header.push(Line::from(""));
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
}
let full_cmd = strip_bash_lc_and_escape(&command);
let mut full_cmd_lines = highlight_bash_to_lines(&full_cmd);
if let Some(first) = full_cmd_lines.first_mut() {
first.spans.insert(0, Span::from("$ "));
let command_snippet = exec_snippet(&command);
if !command_snippet.is_empty() {
header.push(HeaderLine::Text {
text: format!("Command: {command_snippet}"),
italic: false,
});
header.push(HeaderLine::Spacer);
}
header.extend(full_cmd_lines);
Self {
variant: ApprovalVariant::Exec { id, command },
header: Box::new(Paragraph::new(header).wrap(Wrap { trim: false })),
header,
}
}
ApprovalRequest::ApplyPatch {
id,
reason,
cwd,
changes,
grant_root,
} => {
let mut header: Vec<Box<dyn Renderable>> = Vec::new();
header.push(DiffSummary::new(changes, cwd).into());
let mut header = Vec::new();
if let Some(reason) = reason
&& !reason.is_empty()
{
header.push(Box::new(Line::from("")));
header.push(Box::new(
Paragraph::new(reason.italic()).wrap(Wrap { trim: false }),
));
header.push(HeaderLine::Text {
text: reason,
italic: true,
});
header.push(HeaderLine::Spacer);
}
if let Some(root) = grant_root {
header.push(HeaderLine::Text {
text: format!(
"Grant write access to {} for the remainder of this session.",
root.display()
),
italic: false,
});
header.push(HeaderLine::Spacer);
}
Self {
variant: ApprovalVariant::ApplyPatch { id },
header: Box::new(ColumnRenderable::new(header)),
header,
}
}
}
}
}
#[derive(Clone)]
enum ApprovalVariant {
Exec { id: String, command: Vec<String> },
ApplyPatch { id: String },
@@ -349,20 +343,20 @@ fn exec_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve and run now".to_string(),
description: "Run this command one time".to_string(),
description: "(Y) Run this command one time".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
},
ApprovalOption {
label: "Always approve this session".to_string(),
description: "Automatically approve this command for the rest of the session"
description: "(A) Automatically approve this command for the rest of the session"
.to_string(),
decision: ReviewDecision::ApprovedForSession,
shortcut: Some('a'),
},
ApprovalOption {
label: "Cancel".to_string(),
description: "Do not run the command".to_string(),
description: "(N) Do not run the command".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
},
@@ -373,13 +367,13 @@ fn patch_options() -> Vec<ApprovalOption> {
vec![
ApprovalOption {
label: "Approve".to_string(),
description: "Apply the proposed changes".to_string(),
description: "(Y) Apply the proposed changes".to_string(),
decision: ReviewDecision::Approved,
shortcut: Some('y'),
},
ApprovalOption {
label: "Cancel".to_string(),
description: "Do not apply the changes".to_string(),
description: "(N) Do not apply the changes".to_string(),
decision: ReviewDecision::Abort,
shortcut: Some('n'),
},
@@ -522,8 +516,8 @@ mod tests {
};
let view = ApprovalOverlay::new(exec_request, tx);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, view.desired_height(80)));
view.render(Rect::new(0, 0, 80, view.desired_height(80)), &mut buf);
let mut buf = Buffer::empty(Rect::new(0, 0, 80, 6));
view.render(Rect::new(0, 0, 80, 6), &mut buf);
let rendered: Vec<String> = (0..buf.area.height)
.map(|row| {
@@ -535,7 +529,7 @@ mod tests {
assert!(
rendered
.iter()
.any(|line| line.contains("echo hello world")),
.any(|line| line.contains("Command: echo hello world")),
"expected header to include command snippet, got {rendered:?}"
);
}

View File

@@ -1,12 +1,12 @@
use crate::bottom_pane::ApprovalRequest;
use crate::render::renderable::Renderable;
use crossterm::event::KeyEvent;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use super::CancellationEvent;
/// Trait implemented by every view that can be shown in the bottom pane.
pub(crate) trait BottomPaneView: Renderable {
pub(crate) trait BottomPaneView {
/// Handle a key event while the view is active. A redraw is always
/// scheduled after this call.
fn handle_key_event(&mut self, _key_event: KeyEvent) {}
@@ -21,6 +21,12 @@ pub(crate) trait BottomPaneView: Renderable {
CancellationEvent::NotHandled
}
/// Return the desired height of the view.
fn desired_height(&self, width: u16) -> u16;
/// Render the view: this will be displayed in place of the composer.
fn render(&self, area: Rect, buf: &mut Buffer);
/// Optional paste handler. Return true if the view modified its state and
/// needs a redraw.
fn handle_paste(&mut self, _pasted: String) -> bool {

View File

@@ -6,8 +6,6 @@ use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
use super::selection_popup_common::render_rows;
use crate::render::Insets;
use crate::render::RectExt;
use crate::slash_command::SlashCommand;
use crate::slash_command::built_in_slash_commands;
use codex_common::fuzzy_match::fuzzy_match;
@@ -207,12 +205,13 @@ impl WidgetRef for CommandPopup {
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
let rows = self.rows_from_matches(self.filtered());
render_rows(
area.inset(Insets::tlbr(0, 2, 0, 0)),
area,
buf,
&rows,
&self.state,
MAX_POPUP_ROWS,
"no matches",
false,
);
}
}

View File

@@ -12,8 +12,6 @@ use ratatui::widgets::StatefulWidgetRef;
use ratatui::widgets::Widget;
use std::cell::RefCell;
use crate::render::renderable::Renderable;
use super::popup_consts::STANDARD_POPUP_HINT_LINE;
use super::CancellationEvent;
@@ -96,36 +94,6 @@ impl BottomPaneView for CustomPromptView {
self.complete
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl Renderable for CustomPromptView {
fn desired_height(&self, width: u16) -> u16 {
let extra_top: u16 = if self.context_label.is_some() { 1 } else { 0 };
1u16 + extra_top + self.input_height(width) + 3u16
@@ -232,6 +200,34 @@ impl Renderable for CustomPromptView {
);
}
}
fn handle_paste(&mut self, pasted: String) -> bool {
if pasted.is_empty() {
return false;
}
self.textarea.insert_str(&pasted);
true
}
fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> {
if area.height < 2 || area.width <= 2 {
return None;
}
let text_area_height = self.input_height(area.width).saturating_sub(1);
if text_area_height == 0 {
return None;
}
let extra_offset: u16 = if self.context_label.is_some() { 1 } else { 0 };
let top_line_count = 1u16 + extra_offset;
let textarea_rect = Rect {
x: area.x.saturating_add(2),
y: area.y.saturating_add(top_line_count).saturating_add(1),
width: area.width.saturating_sub(2),
height: text_area_height,
};
let state = *self.textarea_state.borrow();
self.textarea.cursor_pos_with_state(textarea_rect, state)
}
}
impl CustomPromptView {

View File

@@ -3,9 +3,6 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::widgets::WidgetRef;
use crate::render::Insets;
use crate::render::RectExt;
use super::popup_consts::MAX_POPUP_ROWS;
use super::scroll_state::ScrollState;
use super::selection_popup_common::GenericDisplayRow;
@@ -142,12 +139,13 @@ impl WidgetRef for &FileSearchPopup {
};
render_rows(
area.inset(Insets::tlbr(0, 2, 0, 0)),
area,
buf,
&rows_all,
&self.state,
MAX_POPUP_ROWS,
empty_message,
false,
);
}
}

View File

@@ -2,23 +2,15 @@ use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;
use ratatui::buffer::Buffer;
use ratatui::layout::Constraint;
use ratatui::layout::Layout;
use ratatui::layout::Rect;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use textwrap::wrap;
use crate::app_event_sender::AppEventSender;
use crate::render::Insets;
use crate::render::RectExt as _;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::style::user_message_style;
use crate::terminal_palette;
use super::CancellationEvent;
use super::bottom_pane_view::BottomPaneView;
@@ -31,6 +23,12 @@ use super::selection_popup_common::render_rows;
/// One selectable item in the generic selection list.
pub(crate) type SelectionAction = Box<dyn Fn(&AppEventSender) + Send + Sync>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum HeaderLine {
Text { text: String, italic: bool },
Spacer,
}
pub(crate) struct SelectionItem {
pub name: String,
pub description: Option<String>,
@@ -40,31 +38,20 @@ pub(crate) struct SelectionItem {
pub search_value: Option<String>,
}
#[derive(Default)]
pub(crate) struct SelectionViewParams {
pub title: Option<String>,
pub title: String,
pub subtitle: Option<String>,
pub footer_hint: Option<String>,
pub items: Vec<SelectionItem>,
pub is_searchable: bool,
pub search_placeholder: Option<String>,
pub header: Box<dyn Renderable>,
}
impl Default for SelectionViewParams {
fn default() -> Self {
Self {
title: None,
subtitle: None,
footer_hint: None,
items: Vec::new(),
is_searchable: false,
search_placeholder: None,
header: Box::new(()),
}
}
pub header: Vec<HeaderLine>,
}
pub(crate) struct ListSelectionView {
title: String,
subtitle: Option<String>,
footer_hint: Option<String>,
items: Vec<SelectionItem>,
state: ScrollState,
@@ -75,22 +62,23 @@ pub(crate) struct ListSelectionView {
search_placeholder: Option<String>,
filtered_indices: Vec<usize>,
last_selected_actual_idx: Option<usize>,
header: Box<dyn Renderable>,
header: Vec<HeaderLine>,
}
impl ListSelectionView {
fn dim_prefix_span() -> Span<'static> {
"".dim()
}
fn render_dim_prefix_line(area: Rect, buf: &mut Buffer) {
let para = Paragraph::new(Line::from(Self::dim_prefix_span()));
para.render(area, buf);
}
pub fn new(params: SelectionViewParams, app_event_tx: AppEventSender) -> Self {
let mut header = params.header;
if params.title.is_some() || params.subtitle.is_some() {
let title = params.title.map(|title| Line::from(title.bold()));
let subtitle = params.subtitle.map(|subtitle| Line::from(subtitle.dim()));
header = Box::new(ColumnRenderable::new([
header,
Box::new(title),
Box::new(subtitle),
]));
}
let mut s = Self {
title: params.title,
subtitle: params.subtitle,
footer_hint: params.footer_hint,
items: params.items,
state: ScrollState::new(),
@@ -105,7 +93,7 @@ impl ListSelectionView {
},
filtered_indices: Vec::new(),
last_selected_actual_idx: None,
header,
header: params.header,
};
s.apply_filter();
s
@@ -183,7 +171,7 @@ impl ListSelectionView {
.filter_map(|(visible_idx, actual_idx)| {
self.items.get(*actual_idx).map(|item| {
let is_selected = self.state.selected_idx == Some(visible_idx);
let prefix = if is_selected { '' } else { ' ' };
let prefix = if is_selected { '>' } else { ' ' };
let name = item.name.as_str();
let name_with_marker = if item.is_current {
format!("{name} (current)")
@@ -191,13 +179,7 @@ impl ListSelectionView {
item.name.clone()
};
let n = visible_idx + 1;
let display_name = if self.is_searchable {
// The number keys don't work when search is enabled (since we let the
// numbers be used for the search query).
format!("{prefix} {name_with_marker}")
} else {
format!("{prefix} {n}. {name_with_marker}")
};
let display_name = format!("{prefix} {n}. {name_with_marker}");
GenericDisplayRow {
name: display_name,
match_indices: None,
@@ -249,6 +231,39 @@ impl ListSelectionView {
pub(crate) fn take_last_selected_index(&mut self) -> Option<usize> {
self.last_selected_actual_idx.take()
}
fn header_spans_for_width(&self, width: u16) -> Vec<Vec<Span<'static>>> {
if self.header.is_empty() || width == 0 {
return Vec::new();
}
let prefix_width = Self::dim_prefix_span().width() as u16;
let available = width.saturating_sub(prefix_width).max(1) as usize;
let mut lines = Vec::new();
for entry in &self.header {
match entry {
HeaderLine::Spacer => lines.push(Vec::new()),
HeaderLine::Text { text, italic } => {
if text.is_empty() {
lines.push(Vec::new());
continue;
}
for part in wrap(text, available) {
let span = if *italic {
Span::from(part.into_owned()).italic()
} else {
Span::from(part.into_owned())
};
lines.push(vec![span]);
}
}
}
}
lines
}
fn header_height(&self, width: u16) -> u16 {
self.header_spans_for_width(width).len() as u16
}
}
impl BottomPaneView for ListSelectionView {
@@ -284,24 +299,6 @@ impl BottomPaneView for ListSelectionView {
self.search_query.push(c);
self.apply_filter();
}
KeyEvent {
code: KeyCode::Char(c),
modifiers,
..
} if !self.is_searchable
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
if let Some(idx) = c
.to_digit(10)
.map(|d| d as usize)
.and_then(|d| d.checked_sub(1))
&& idx < self.items.len()
{
self.state.selected_idx = Some(idx);
self.accept();
}
}
KeyEvent {
code: KeyCode::Enter,
modifiers: KeyModifiers::NONE,
@@ -319,9 +316,7 @@ impl BottomPaneView for ListSelectionView {
self.complete = true;
CancellationEvent::Handled
}
}
impl Renderable for ListSelectionView {
fn desired_height(&self, width: u16) -> u16 {
// Measure wrapped height for up to MAX_POPUP_ROWS items at the given width.
// Build the same display rows used by the renderer so wrapping math matches.
@@ -329,14 +324,20 @@ impl Renderable for ListSelectionView {
let rows_height = measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, width);
let mut height = self.header.desired_height(width);
height = height.saturating_add(rows_height + 3);
// +1 for the title row, +1 for a spacer line beneath the header,
// +1 for optional subtitle, +1 for optional footer (2 lines incl. spacing)
let mut height = self.header_height(width);
height = height.saturating_add(rows_height + 2);
if self.is_searchable {
height = height.saturating_add(1);
}
if self.footer_hint.is_some() {
if self.subtitle.is_some() {
// +1 for subtitle (the spacer is accounted for above)
height = height.saturating_add(1);
}
if self.footer_hint.is_some() {
height = height.saturating_add(2);
}
height
}
@@ -345,42 +346,52 @@ impl Renderable for ListSelectionView {
return;
}
let [content_area, footer_area] = Layout::vertical([
Constraint::Fill(1),
Constraint::Length(if self.footer_hint.is_some() { 1 } else { 0 }),
])
.areas(area);
Block::default()
.style(user_message_style(terminal_palette::default_bg()))
.render(content_area, buf);
let header_height = self.header.desired_height(content_area.width);
let rows = self.build_rows();
let rows_height =
measure_rows_height(&rows, &self.state, MAX_POPUP_ROWS, content_area.width);
let [header_area, _, search_area, list_area] = Layout::vertical([
Constraint::Max(header_height),
Constraint::Max(1),
Constraint::Length(if self.is_searchable { 1 } else { 0 }),
Constraint::Length(rows_height),
])
.areas(content_area.inset(Insets::vh(1, 2)));
if header_area.height < header_height {
let [header_area, elision_area] =
Layout::vertical([Constraint::Fill(1), Constraint::Length(1)]).areas(header_area);
self.header.render(header_area, buf);
Paragraph::new(vec![
Line::from(format!("[… {header_height} lines] ctrl + a view all")).dim(),
])
.render(elision_area, buf);
} else {
self.header.render(header_area, buf);
let mut next_y = area.y;
let header_spans = self.header_spans_for_width(area.width);
for spans in header_spans.into_iter() {
if next_y >= area.y + area.height {
return;
}
let row = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let mut prefixed: Vec<Span<'static>> = vec![Self::dim_prefix_span()];
if spans.is_empty() {
prefixed.push(String::new().into());
} else {
prefixed.extend(spans);
}
Paragraph::new(Line::from(prefixed)).render(row, buf);
next_y = next_y.saturating_add(1);
}
if self.is_searchable {
Line::from(self.search_query.clone()).render(search_area, buf);
if next_y >= area.y + area.height {
return;
}
let title_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![
Self::dim_prefix_span(),
self.title.clone().bold(),
]))
.render(title_area, buf);
next_y = next_y.saturating_add(1);
if self.is_searchable && next_y < area.y + area.height {
let search_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
let query_span: Span<'static> = if self.search_query.is_empty() {
self.search_placeholder
.as_ref()
@@ -389,40 +400,80 @@ impl Renderable for ListSelectionView {
} else {
self.search_query.clone().into()
};
Line::from(query_span).render(search_area, buf);
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), query_span]))
.render(search_area, buf);
next_y = next_y.saturating_add(1);
}
if list_area.height > 0 {
let list_area = Rect {
x: list_area.x - 2,
y: list_area.y,
width: list_area.width + 2,
height: list_area.height,
if let Some(sub) = &self.subtitle {
if next_y >= area.y + area.height {
return;
}
let subtitle_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Paragraph::new(Line::from(vec![Self::dim_prefix_span(), sub.clone().dim()]))
.render(subtitle_area, buf);
next_y = next_y.saturating_add(1);
}
if next_y >= area.y + area.height {
return;
}
let spacer_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: 1,
};
Self::render_dim_prefix_line(spacer_area, buf);
next_y = next_y.saturating_add(1);
let footer_reserved = if self.footer_hint.is_some() { 2 } else { 0 };
if next_y >= area.y + area.height {
return;
}
let rows_area = Rect {
x: area.x,
y: next_y,
width: area.width,
height: area
.height
.saturating_sub(next_y.saturating_sub(area.y))
.saturating_sub(footer_reserved),
};
let rows = self.build_rows();
if rows_area.height > 0 {
render_rows(
list_area,
rows_area,
buf,
&rows,
&self.state,
list_area.height as usize,
MAX_POPUP_ROWS,
"no matches",
true,
);
}
if let Some(hint) = &self.footer_hint {
let hint_area = Rect {
x: footer_area.x + 2,
y: footer_area.y,
width: footer_area.width.saturating_sub(2),
height: footer_area.height,
let footer_area = Rect {
x: area.x,
y: area.y + area.height - 1,
width: area.width,
height: 1,
};
Line::from(hint.clone().dim()).render(hint_area, buf);
Paragraph::new(hint.clone().dim()).render(footer_area, buf);
}
}
}
#[cfg(test)]
mod tests {
use super::BottomPaneView;
use super::*;
use crate::app_event::AppEvent;
use crate::bottom_pane::popup_consts::STANDARD_POPUP_HINT_LINE;
@@ -453,7 +504,7 @@ mod tests {
];
ListSelectionView::new(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
title: "Select Approval Mode".to_string(),
subtitle: subtitle.map(str::to_string),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
@@ -465,7 +516,7 @@ mod tests {
fn render_lines(view: &ListSelectionView) -> String {
let width = 48;
let height = view.desired_height(width);
let height = BottomPaneView::desired_height(view, width);
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
view.render(area, &mut buf);
@@ -516,7 +567,7 @@ mod tests {
}];
let mut view = ListSelectionView::new(
SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
title: "Select Approval Mode".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
@@ -528,9 +579,6 @@ mod tests {
view.set_search_query("filters".to_string());
let lines = render_lines(&view);
assert!(
lines.contains("filters"),
"expected search query line to include rendered query, got {lines:?}"
);
assert!(lines.contains("▌ filters"));
}
}

View File

@@ -3,13 +3,17 @@ use ratatui::layout::Rect;
// Note: Table-based layout previously used Constraint; the manual renderer
// below no longer requires it.
use ratatui::style::Color;
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::Widget;
use unicode_width::UnicodeWidthChar;
use super::scroll_state::ScrollState;
use crate::ui_consts::LIVE_PREFIX_COLS;
/// A generic representation of a display row for selection popups.
pub(crate) struct GenericDisplayRow {
@@ -19,6 +23,8 @@ pub(crate) struct GenericDisplayRow {
pub description: Option<String>, // optional grey text after the name
}
impl GenericDisplayRow {}
/// Compute a shared description-column start based on the widest visible name
/// plus two spaces of padding. Ensures at least one column is left for the
/// description.
@@ -111,19 +117,71 @@ pub(crate) fn render_rows(
state: &ScrollState,
max_results: usize,
empty_message: &str,
include_border: bool,
) {
if include_border {
use ratatui::widgets::Block;
use ratatui::widgets::BorderType;
use ratatui::widgets::Borders;
// Always draw a dim left border to match other popups.
let block = Block::default()
.borders(Borders::LEFT)
.border_type(BorderType::QuadrantOutside)
.border_style(Style::default().add_modifier(Modifier::DIM));
block.render(area, buf);
}
// Content renders to the right of the border with the same live prefix
// padding used by the composer so the popup aligns with the input text.
let prefix_cols = LIVE_PREFIX_COLS;
let content_area = Rect {
x: area.x.saturating_add(prefix_cols),
y: area.y,
width: area.width.saturating_sub(prefix_cols),
height: area.height,
};
// Clear the padding column(s) so stale characters never peek between the
// border and the popup contents.
let padding_cols = prefix_cols.saturating_sub(1);
if padding_cols > 0 {
let pad_start = area.x.saturating_add(1);
let pad_end = pad_start
.saturating_add(padding_cols)
.min(area.x.saturating_add(area.width));
let pad_bottom = area.y.saturating_add(area.height);
for x in pad_start..pad_end {
for y in area.y..pad_bottom {
if let Some(cell) = buf.cell_mut((x, y)) {
cell.set_symbol(" ");
}
}
}
}
if rows_all.is_empty() {
if area.height > 0 {
Line::from(empty_message.dim().italic()).render(area, buf);
if content_area.height > 0 {
let para = Paragraph::new(Line::from(empty_message.dim().italic()));
para.render(
Rect {
x: content_area.x,
y: content_area.y,
width: content_area.width,
height: 1,
},
buf,
);
}
return;
}
// Determine which logical rows (items) are visible given the selection and
// the max_results clamp. Scrolling is still item-based for simplicity.
let max_rows_from_area = content_area.height as usize;
let visible_items = max_results
.min(rows_all.len())
.min(area.height.max(1) as usize);
.min(max_rows_from_area.max(1));
let mut start_idx = state.scroll_top.min(rows_all.len().saturating_sub(1));
if let Some(sel) = state.selected_idx {
@@ -137,18 +195,18 @@ pub(crate) fn render_rows(
}
}
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, area.width);
let desc_col = compute_desc_col(rows_all, start_idx, visible_items, content_area.width);
// Render items, wrapping descriptions and aligning wrapped lines under the
// shared description column. Stop when we run out of vertical space.
let mut cur_y = area.y;
let mut cur_y = content_area.y;
for (i, row) in rows_all
.iter()
.enumerate()
.skip(start_idx)
.take(visible_items)
{
if cur_y >= area.y + area.height {
if cur_y >= content_area.y + content_area.height {
break;
}
@@ -159,7 +217,7 @@ pub(crate) fn render_rows(
description,
} = row;
let mut full_line = build_full_line(
let full_line = build_full_line(
&GenericDisplayRow {
name: name.clone(),
match_indices: match_indices.clone(),
@@ -168,31 +226,32 @@ pub(crate) fn render_rows(
},
desc_col,
);
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
full_line.spans.iter_mut().for_each(|span| {
span.style = span.style.fg(Color::Cyan).bold();
});
}
// Wrap with subsequent indent aligned to the description column.
use crate::wrapping::RtOptions;
use crate::wrapping::word_wrap_line;
let options = RtOptions::new(area.width as usize)
let options = RtOptions::new(content_area.width as usize)
.initial_indent(Line::from(""))
.subsequent_indent(Line::from(" ".repeat(desc_col)));
let wrapped = word_wrap_line(&full_line, options);
// Render the wrapped lines.
for line in wrapped {
if cur_y >= area.y + area.height {
for mut line in wrapped {
if cur_y >= content_area.y + content_area.height {
break;
}
line.render(
if Some(i) == state.selected_idx {
// Match previous behavior: cyan + bold for the selected row.
line.style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
}
let para = Paragraph::new(line);
para.render(
Rect {
x: area.x,
x: content_area.x,
y: cur_y,
width: area.width,
width: content_area.width,
height: 1,
},
buf,

View File

@@ -2,11 +2,10 @@
source: tui/src/bottom_pane/list_selection_view.rs
expression: render_lines(&view)
---
▌ Select Approval Mode
▌ Switch between Codex approval presets
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Select Approval Mode
Switch between Codex approval presets
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back
Press Enter to confirm or Esc to go back

View File

@@ -2,10 +2,9 @@
source: tui/src/bottom_pane/list_selection_view.rs
expression: render_lines(&view)
---
▌ Select Approval Mode
▌ > 1. Read Only (current) Codex can read files
▌ 2. Full Access Codex can edit files
Select Approval Mode
1. Read Only (current) Codex can read files
2. Full Access Codex can edit files
Press Enter to confirm or Esc to go back
Press Enter to confirm or Esc to go back

View File

@@ -79,6 +79,7 @@ use crate::history_cell;
use crate::history_cell::AgentMessageCell;
use crate::history_cell::HistoryCell;
use crate::history_cell::McpToolCallCell;
use crate::history_cell::PatchEventType;
use crate::markdown::append_markdown;
use crate::slash_command::SlashCommand;
use crate::status::RateLimitSnapshotDisplay;
@@ -533,6 +534,9 @@ impl ChatWidget {
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: event.auto_approved,
},
event.changes,
&self.config.cwd,
));
@@ -732,6 +736,8 @@ impl ChatWidget {
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
self.flush_answer_stream_with_separator();
// Emit the proposed command into history (like proposed patches)
self.add_to_history(history_cell::new_proposed_command(&ev.command));
let command = shlex::try_join(ev.command.iter().map(String::as_str))
.unwrap_or_else(|_| ev.command.join(" "));
self.notify(Notification::ExecApprovalRequested { command });
@@ -751,12 +757,16 @@ impl ChatWidget {
ev: ApplyPatchApprovalRequestEvent,
) {
self.flush_answer_stream_with_separator();
self.add_to_history(history_cell::new_patch_event(
PatchEventType::ApprovalRequest,
ev.changes.clone(),
&self.config.cwd,
));
let request = ApprovalRequest::ApplyPatch {
id,
reason: ev.reason,
changes: ev.changes.clone(),
cwd: self.config.cwd.clone(),
grant_root: ev.grant_root,
};
self.bottom_pane.push_approval_request(request);
self.request_redraw();
@@ -1621,7 +1631,7 @@ impl ChatWidget {
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select model and reasoning level".to_string()),
title: "Select model and reasoning level".to_string(),
subtitle: Some(
"Switch between OpenAI models for this and future Codex CLI session".to_string(),
),
@@ -1667,7 +1677,7 @@ impl ChatWidget {
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select Approval Mode".to_string()),
title: "Select Approval Mode".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
..Default::default()
@@ -1842,7 +1852,7 @@ impl ChatWidget {
});
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a review preset".into()),
title: "Select a review preset".into(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
..Default::default()
@@ -1878,7 +1888,7 @@ impl ChatWidget {
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a base branch".to_string()),
title: "Select a base branch".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
@@ -1919,7 +1929,7 @@ impl ChatWidget {
}
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
title: "Select a commit to review".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,
@@ -2144,7 +2154,7 @@ pub(crate) fn show_review_commit_picker_with_entries(
}
chat.bottom_pane.show_selection_view(SelectionViewParams {
title: Some("Select a commit to review".to_string()),
title: "Select a commit to review".to_string(),
footer_hint: Some(STANDARD_POPUP_HINT_LINE.to_string()),
items,
is_searchable: true,

View File

@@ -2,5 +2,4 @@
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&approved_lines)
---
Added foo.txt (+1 -0)
1 +hello
Change Approved foo.txt (+1 -0)

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_lines)
---
• Proposed Change foo.txt (+1 -0)
1 +hello

View File

@@ -1,16 +1,18 @@
---
source: tui/src/chatwidget/tests.rs
expression: terminal.backend().vt100().screen().contents()
expression: terminal.backend()
---
Allow command?
this is a test reason such as one that would be produced by the model
$ echo hello world
1. Approve and run now Run this command one time
2. Always approve this session Automatically approve this command for the
rest of the session
3. Cancel Do not run the command
Press Enter to confirm or Esc to cancel
" "
"▌ this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,15 +3,14 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌ Command: echo hello world "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
" Allow command? "
" "
" $ echo hello world "
" "
" 1. Approve and run now Run this command one time "
" 2. Always approve this session Automatically approve this command for the "
" rest of the session "
" 3. Cancel Do not run the command "
" "
" Press Enter to confirm or Esc to cancel "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -3,17 +3,14 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌ The model wants to apply changes "
"▌ "
"▌ Grant write access to /tmp for the remainder of this session. "
"▌ "
"▌ Apply changes? "
"▌ "
"▌ > 1. Approve (Y) Apply the proposed changes "
"▌ 2. Cancel (N) Do not apply the changes "
" "
" Apply changes? "
" "
" README.md (+2 -0) "
" 1 +hello "
" 2 +world "
" "
" The model wants to apply changes "
" "
" 1. Approve Apply the proposed changes "
" 2. Cancel Do not apply the changes "
" "
" Press Enter to confirm or Esc to cancel "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -0,0 +1,7 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed_multi)
---
• Proposed Command
└ echo line1
echo line2

View File

@@ -0,0 +1,6 @@
---
source: tui/src/chatwidget/tests.rs
expression: lines_to_single_string(&proposed)
---
• Proposed Command
└ echo hello world

View File

@@ -1,42 +0,0 @@
---
source: tui/src/chatwidget/tests.rs
expression: "format!(\"{buf:?}\")"
---
Buffer {
area: Rect { x: 0, y: 0, width: 80, height: 15 },
content: [
" ",
" ",
" Allow command? ",
" ",
" this is a test reason such as one that would be produced by the model ",
" ",
" $ echo hello world ",
" ",
" 1. Approve and run now Run this command one time ",
" 2. Always approve this session Automatically approve this command for the ",
" rest of the session ",
" 3. Cancel Do not run the command ",
" ",
" Press Enter to confirm or Esc to cancel ",
" ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 16, y: 2, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: ITALIC,
x: 71, y: 4, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 8, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD,
x: 34, y: 8, fg: Cyan, bg: Reset, underline: Reset, modifier: BOLD | DIM,
x: 59, y: 8, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 76, y: 9, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 53, y: 10, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 34, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 56, y: 11, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 2, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: DIM,
x: 41, y: 13, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}

View File

@@ -3,17 +3,16 @@ source: tui/src/chatwidget/tests.rs
expression: terminal.backend()
---
" "
"▌ this is a test reason such as one that would be produced by the model "
"▌ "
"▌ Command: echo 'hello world' "
"▌ "
"▌ Allow command? "
"▌ "
"▌ > 1. Approve and run now (Y) Run this command one time "
"▌ 2. Always approve this session (A) Automatically approve this command for "
"▌ the rest of the session "
"▌ 3. Cancel (N) Do not run the command "
" "
" Allow command? "
" "
" this is a test reason such as one that would be produced by the model "
" "
" $ echo 'hello world' "
" "
" 1. Approve and run now Run this command one time "
" 2. Always approve this session Automatically approve this command for the "
" rest of the session "
" 3. Cancel Do not run the command "
" "
" Press Enter to confirm or Esc to cancel "
"Press Enter to confirm or Esc to cancel "
" "

View File

@@ -2,7 +2,6 @@ use super::*;
use crate::app_event::AppEvent;
use crate::app_event_sender::AppEventSender;
use crate::test_backend::VT100Backend;
use crate::tui::FrameRequester;
use codex_core::AuthManager;
use codex_core::CodexAuth;
use codex_core::config::Config;
@@ -83,6 +82,66 @@ fn upgrade_event_payload_for_tests(mut payload: serde_json::Value) -> serde_json
payload
}
/*
#[test]
fn final_answer_without_newline_is_flushed_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Set up a VT100 test terminal to capture ANSI visual output
let width: u16 = 80;
// Increased height to keep the initial banner/help lines in view even if
// the session renders an extra header line or minor layout changes occur.
let height: u16 = 2500;
let viewport = Rect::new(0, height - 1, width, 1);
let backend = ratatui::backend::TestBackend::new(width, height);
let mut terminal = crate::custom_terminal::Terminal::with_options(backend)
.expect("failed to construct terminal");
terminal.set_viewport_area(viewport);
// Simulate a streaming answer without any newline characters.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessageDelta(AgentMessageDeltaEvent {
delta: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Now simulate the final AgentMessage which should flush the pending line immediately.
chat.handle_codex_event(Event {
id: "sub-a".into(),
msg: EventMsg::AgentMessage(AgentMessageEvent {
message: "Hi! How can I help with codex-rs or anything else today?".into(),
}),
});
// Drain history insertions and verify the final line is present.
let cells = drain_insert_history(&mut rx);
assert!(
cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("codex")
}),
"expected 'codex' header to be emitted",
);
let found_final = cells.iter().any(|lines| {
let s = lines
.iter()
.flat_map(|l| l.spans.iter())
.map(|sp| sp.content.clone())
.collect::<String>();
s.contains("Hi! How can I help with codex-rs or anything else today?")
});
assert!(
found_final,
"expected final answer text to be flushed to history"
);
}
*/
#[test]
fn resumed_initial_messages_render_history() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual();
@@ -392,18 +451,15 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
msg: EventMsg::ExecApprovalRequest(ev),
});
let proposed_cells = drain_insert_history(&mut rx);
assert!(
proposed_cells.is_empty(),
"expected approval request to render via modal without emitting history cells"
// Snapshot the Proposed Command cell emitted into history
let proposed = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed command cell");
assert_snapshot!(
"exec_approval_history_proposed_short",
lines_to_single_string(&proposed)
);
// The approval modal should display the command snippet for user confirmation.
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
assert_snapshot!("exec_approval_modal_exec", format!("{buf:?}"));
// Approve via keyboard and verify a concise decision history line is added
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
let decision = drain_insert_history(&mut rx)
@@ -419,7 +475,7 @@ fn exec_approval_emits_proposed_command_and_decision_history() {
fn exec_approval_decision_truncates_multiline_and_long_commands() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual();
// Multiline command: modal should show full command, history records decision only
// Multiline command: should render proposed command fully in history with prefixes
let ev_multi = ExecApprovalRequestEvent {
call_id: "call-multi".into(),
command: vec!["bash".into(), "-lc".into(), "echo line1\necho line2".into()],
@@ -432,29 +488,12 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
id: "sub-multi".into(),
msg: EventMsg::ExecApprovalRequest(ev_multi),
});
let proposed_multi = drain_insert_history(&mut rx);
assert!(
proposed_multi.is_empty(),
"expected multiline approval request to render via modal without emitting history cells"
);
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_first_line = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("echo line1") {
saw_first_line = true;
break;
}
}
assert!(
saw_first_line,
"expected modal to show first line of multiline snippet"
let proposed_multi = drain_insert_history(&mut rx)
.pop()
.expect("expected proposed multiline command cell");
assert_snapshot!(
"exec_approval_history_proposed_multiline",
lines_to_single_string(&proposed_multi)
);
// Deny via keyboard; decision snippet should be single-line and elided with " ..."
@@ -479,11 +518,7 @@ fn exec_approval_decision_truncates_multiline_and_long_commands() {
id: "sub-long".into(),
msg: EventMsg::ExecApprovalRequest(ev_long),
});
let proposed_long = drain_insert_history(&mut rx);
assert!(
proposed_long.is_empty(),
"expected long approval request to avoid emitting history cells before decision"
);
drain_insert_history(&mut rx); // proposed cell not needed for this assertion
chat.handle_key_event(KeyEvent::new(KeyCode::Char('n'), KeyModifiers::NONE));
let aborted_long = drain_insert_history(&mut rx)
.pop()
@@ -899,21 +934,18 @@ fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String {
let area = Rect::new(0, 0, width, height);
let mut buf = Buffer::empty(area);
(chat).render_ref(area, &mut buf);
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
let s = buf[(x, y)].symbol();
if s.is_empty() {
row.push(' ');
} else {
row.push_str(s);
}
}
if !row.trim().is_empty() {
return row;
let mut row = String::new();
// Row 0 is the top spacer for the bottom pane; row 1 contains the header line
let y = 1u16.min(height.saturating_sub(1));
for x in 0..area.width {
let s = buf[(x, y)].symbol();
if s.is_empty() {
row.push(' ');
} else {
row.push_str(s);
}
}
String::new()
row
}
#[test]
@@ -1148,19 +1180,12 @@ fn approval_modal_exec_snapshot() {
// Render to a fixed-size test terminal and snapshot.
// Call desired_height first and use that exact height for rendering.
let height = chat.desired_height(80);
let mut terminal =
crate::custom_terminal::Terminal::with_options(VT100Backend::new(80, height))
.expect("create terminal");
let viewport = Rect::new(0, 0, 80, height);
terminal.set_viewport_area(viewport);
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");
assert_snapshot!(
"approval_modal_exec",
terminal.backend().vt100().screen().contents()
);
assert_snapshot!("approval_modal_exec", terminal.backend());
}
// Snapshot test: command approval modal without a reason
@@ -1444,27 +1469,13 @@ fn apply_patch_events_emit_history_cells() {
msg: EventMsg::ApplyPatchApprovalRequest(ev),
});
let cells = drain_insert_history(&mut rx);
assert!(!cells.is_empty(), "expected pending patch cell to be sent");
let blob = lines_to_single_string(cells.last().unwrap());
assert!(
cells.is_empty(),
"expected approval request to surface via modal without emitting history cells"
blob.contains("Proposed Change"),
"missing proposed change header: {blob:?}"
);
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_summary = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("foo.txt (+1 -0)") {
saw_summary = true;
break;
}
}
assert!(saw_summary, "expected approval modal to show diff summary");
// 2) Begin apply -> per-file apply block cell (no global header)
let mut changes2 = HashMap::new();
changes2.insert(
@@ -1550,8 +1561,8 @@ fn apply_patch_manual_approval_adjusts_header() {
assert!(!cells.is_empty(), "expected apply block 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 apply summary header for foo.txt: {blob:?}"
blob.contains("Change Approved foo.txt"),
"expected change approved summary: {blob:?}"
);
}
@@ -1575,11 +1586,9 @@ fn apply_patch_manual_flow_snapshot() {
grant_root: None,
}),
});
let history_before_apply = drain_insert_history(&mut rx);
assert!(
history_before_apply.is_empty(),
"expected approval modal to defer history emission"
);
let proposed_lines = drain_insert_history(&mut rx)
.pop()
.expect("proposed patch cell");
let mut apply_changes = HashMap::new();
apply_changes.insert(
@@ -1600,6 +1609,10 @@ fn apply_patch_manual_flow_snapshot() {
.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)
@@ -1789,42 +1802,24 @@ fn apply_patch_request_shows_diff_summary() {
}),
});
// No history entries yet; the modal should contain the diff summary
// Drain history insertions and verify the diff summary is present
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected approval request to render via modal instead of history"
!cells.is_empty(),
"expected a history cell with the proposed patch summary"
);
let blob = lines_to_single_string(cells.last().unwrap());
// Header should summarize totals
assert!(
blob.contains("Proposed Change README.md (+2 -0)"),
"missing or incorrect diff header: {blob:?}"
);
let area = Rect::new(0, 0, 80, chat.desired_height(80));
let mut buf = ratatui::buffer::Buffer::empty(area);
(&chat).render_ref(area, &mut buf);
let mut saw_header = false;
let mut saw_line1 = false;
let mut saw_line2 = false;
for y in 0..area.height {
let mut row = String::new();
for x in 0..area.width {
row.push(buf[(x, y)].symbol().chars().next().unwrap_or(' '));
}
if row.contains("README.md (+2 -0)") {
saw_header = true;
}
if row.contains("+line one") {
saw_line1 = true;
}
if row.contains("+line two") {
saw_line2 = true;
}
if saw_header && saw_line1 && saw_line2 {
break;
}
}
assert!(saw_header, "expected modal to show diff header with totals");
// Per-file summary line should include the file path and counts
assert!(
saw_line1 && saw_line2,
"expected modal to show per-line diff summary"
blob.contains("README.md"),
"missing per-file diff summary: {blob:?}"
);
}

View File

@@ -1,20 +1,16 @@
use diffy::Hunk;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Color;
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 ratatui::widgets::Paragraph;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use crate::exec_command::relativize_to_home;
use crate::render::renderable::ColumnRenderable;
use crate::render::renderable::Renderable;
use crate::history_cell::PatchEventType;
use codex_core::git_info::get_git_repo_root;
use codex_core::protocol::FileChange;
@@ -27,57 +23,24 @@ enum DiffLineType {
Context,
}
pub struct DiffSummary {
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
}
impl DiffSummary {
pub fn new(changes: HashMap<PathBuf, FileChange>, cwd: PathBuf) -> Self {
Self { changes, cwd }
}
}
impl Renderable for FileChange {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut lines = vec![];
render_change(self, &mut lines, area.width as usize);
Paragraph::new(lines).render(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
let mut lines = vec![];
render_change(self, &mut lines, width as usize);
lines.len() as u16
}
}
impl From<DiffSummary> for Box<dyn Renderable> {
fn from(val: DiffSummary) -> Self {
let mut rows: Vec<Box<dyn Renderable>> = vec![];
for (i, row) in collect_rows(&val.changes).into_iter().enumerate() {
if i > 0 {
rows.push(Box::new(RtLine::from("")));
}
let mut path = RtLine::from(display_path_for(&row.path, &val.cwd));
path.push_span(" ");
path.extend(render_line_count_summary(row.added, row.removed));
rows.push(Box::new(path));
rows.push(Box::new(row.change));
}
Box::new(ColumnRenderable::new(rows))
}
}
pub(crate) fn create_diff_summary(
changes: &HashMap<PathBuf, FileChange>,
event_type: PatchEventType,
cwd: &Path,
wrap_cols: usize,
) -> Vec<RtLine<'static>> {
let rows = collect_rows(changes);
render_changes_block(rows, wrap_cols, cwd)
let header_kind = match event_type {
PatchEventType::ApplyBegin { auto_approved } => {
if auto_approved {
HeaderKind::Edited
} else {
HeaderKind::ChangeApproved
}
}
PatchEventType::ApprovalRequest => HeaderKind::ProposedChange,
};
render_changes_block(rows, wrap_cols, header_kind, cwd)
}
// Shared row for per-file presentation
@@ -118,18 +81,30 @@ fn collect_rows(changes: &HashMap<PathBuf, FileChange>) -> Vec<Row> {
rows
}
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
enum HeaderKind {
ProposedChange,
Edited,
ChangeApproved,
}
fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtLine<'static>> {
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
}
let render_path = |row: &Row| -> Vec<RtSpan<'static>> {
let mut spans = Vec::new();
@@ -146,31 +121,66 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
let file_count = rows.len();
let noun = if file_count == 1 { "file" } else { "files" };
let mut header_spans: Vec<RtSpan<'static>> = vec!["".into()];
if let [row] = &rows[..] {
let verb = match &row.change {
FileChange::Add { .. } => "Added",
FileChange::Delete { .. } => "Deleted",
_ => "Edited",
};
header_spans.push(verb.bold());
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push("Edited".bold());
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
match header_kind {
HeaderKind::ProposedChange => {
header_spans.push("Proposed Change".bold());
if let [row] = &rows[..] {
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push(format!(" to {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
HeaderKind::Edited => {
if let [row] = &rows[..] {
let verb = match &row.change {
FileChange::Add { .. } => "Added",
FileChange::Delete { .. } => "Deleted",
_ => "Edited",
};
header_spans.push(verb.bold());
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push("Edited".bold());
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
HeaderKind::ChangeApproved => {
header_spans.push("Change Approved".bold());
if let [row] = &rows[..] {
header_spans.push(" ".into());
header_spans.extend(render_path(row));
header_spans.push(" ".into());
header_spans.extend(render_line_count_summary(row.added, row.removed));
} else {
header_spans.push(format!(" {file_count} {noun} ").into());
header_spans.extend(render_line_count_summary(total_added, total_removed));
}
}
}
out.push(RtLine::from(header_spans));
// For Change Approved, we only show the header summary and no per-file/diff details.
if matches!(header_kind, HeaderKind::ChangeApproved) {
return out;
}
for (idx, r) in rows.into_iter().enumerate() {
// Insert a blank separator between file chunks (except before the first)
if idx > 0 {
out.push("".into());
}
// File header line (skip when single-file header already shows the name)
let skip_file_header = file_count == 1;
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());
@@ -180,77 +190,71 @@ fn render_changes_block(rows: Vec<Row>, wrap_cols: usize, cwd: &Path) -> Vec<RtL
out.push(RtLine::from(header));
}
render_change(&r.change, &mut out, wrap_cols);
}
out
}
fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usize) {
match change {
FileChange::Add { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
width,
));
match r.change {
FileChange::Add { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Insert,
raw,
term_cols,
));
}
}
}
FileChange::Delete { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
width,
));
FileChange::Delete { content } => {
for (i, raw) in content.lines().enumerate() {
out.extend(push_wrapped_diff_line(
i + 1,
DiffLineType::Delete,
raw,
term_cols,
));
}
}
}
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(unified_diff) {
let mut is_first_hunk = true;
for h in patch.hunks() {
if !is_first_hunk {
out.push(RtLine::from(vec![" ".into(), "".dim()]));
}
is_first_hunk = false;
FileChange::Update { unified_diff, .. } => {
if let Ok(patch) = diffy::Patch::from_str(&unified_diff) {
let mut is_first_hunk = true;
for h in patch.hunks() {
if !is_first_hunk {
out.push(RtLine::from(vec![" ".into(), "".dim()]));
}
is_first_hunk = false;
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
width,
));
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
width,
));
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
width,
));
old_ln += 1;
new_ln += 1;
let mut old_ln = h.old_range().start();
let mut new_ln = h.new_range().start();
for l in h.lines() {
match l {
diffy::Line::Insert(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Insert,
s,
term_cols,
));
new_ln += 1;
}
diffy::Line::Delete(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
old_ln,
DiffLineType::Delete,
s,
term_cols,
));
old_ln += 1;
}
diffy::Line::Context(text) => {
let s = text.trim_end_matches('\n');
out.extend(push_wrapped_diff_line(
new_ln,
DiffLineType::Context,
s,
term_cols,
));
old_ln += 1;
new_ln += 1;
}
}
}
}
@@ -258,6 +262,8 @@ fn render_change(change: &FileChange, out: &mut Vec<RtLine<'static>>, width: usi
}
}
}
out
}
pub(crate) fn display_path_for(path: &Path, cwd: &Path) -> String {
@@ -294,7 +300,7 @@ fn push_wrapped_diff_line(
line_number: usize,
kind: DiffLineType,
text: &str,
width: usize,
term_cols: usize,
) -> Vec<RtLine<'static>> {
let indent = " ";
let ln_str = line_number.to_string();
@@ -319,7 +325,7 @@ fn push_wrapped_diff_line(
// Fit the content for the current terminal row:
// compute how many columns are available after the prefix, then split
// at a UTF-8 character boundary so this row's chunk fits exactly.
let available_content_cols = width.saturating_sub(prefix_cols + 1).max(1);
let available_content_cols = term_cols.saturating_sub(prefix_cols + 1).max(1);
let split_at_byte_index = remaining_text
.char_indices()
.nth(available_content_cols)
@@ -379,8 +385,11 @@ mod tests {
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
fn diff_summary_for_tests(changes: &HashMap<PathBuf, FileChange>) -> Vec<RtLine<'static>> {
create_diff_summary(changes, &PathBuf::from("/"), 80)
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) {
@@ -412,6 +421,42 @@ mod tests {
assert_snapshot!(name, text);
}
#[test]
fn ui_snapshot_add_details() {
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Add {
content: "first line\nsecond line\n".to_string(),
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("add_details", lines, 80, 10);
}
#[test]
fn ui_snapshot_update_details_with_rename() {
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("src/lib.rs"),
FileChange::Update {
unified_diff: patch,
move_path: Some(PathBuf::from("src/lib_new.rs")),
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("update_details_with_rename", lines, 80, 12);
}
#[test]
fn ui_snapshot_wrap_behavior_insert() {
// Narrow width to force wrapping within our diff line rendering
@@ -424,6 +469,71 @@ mod tests {
snapshot_lines("wrap_behavior_insert", lines, 90, 8);
}
#[test]
fn ui_snapshot_single_line_replacement_counts() {
// Reproduce: one deleted line replaced by one inserted line, no extra context
let original = "# Codex CLI (Rust Implementation)\n";
let modified = "# Codex CLI (Rust Implementation) banana\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("README.md"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("single_line_replacement_counts", lines, 80, 8);
}
#[test]
fn ui_snapshot_blank_context_line() {
// Ensure a hunk that includes a blank context line at the beginning is rendered visibly
let original = "\nY\n";
let modified = "\nY changed\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&changes, PatchEventType::ApprovalRequest);
snapshot_lines("blank_context_line", lines, 80, 10);
}
#[test]
fn ui_snapshot_vertical_ellipsis_between_hunks() {
// Create a patch with two separate hunks to ensure we render the vertical ellipsis (⋮)
let original =
"line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10\n";
let modified = "line 1\nline two changed\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline nine changed\nline 10\n";
let patch = diffy::create_patch(original, modified).to_string();
let mut changes: HashMap<PathBuf, FileChange> = HashMap::new();
changes.insert(
PathBuf::from("example.txt"),
FileChange::Update {
unified_diff: patch,
move_path: None,
},
);
let lines = diff_summary_for_tests(&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();
@@ -439,8 +549,12 @@ mod tests {
},
);
for name in ["apply_update_block", "apply_update_block_manual"] {
let lines = diff_summary_for_tests(&changes);
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);
}
@@ -461,7 +575,12 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_update_with_rename_block", lines, 80, 12);
}
@@ -489,7 +608,12 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_multiple_files_block", lines, 80, 14);
}
@@ -504,7 +628,12 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes);
let lines = diff_summary_for_tests(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
);
snapshot_lines("apply_add_block", lines, 80, 10);
}
@@ -523,7 +652,12 @@ mod tests {
},
);
let lines = diff_summary_for_tests(&changes);
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);
@@ -547,7 +681,14 @@ mod tests {
},
);
let lines = create_diff_summary(&changes, &PathBuf::from("/"), 72);
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);
@@ -570,7 +711,14 @@ mod tests {
},
);
let mut lines = create_diff_summary(&changes, &PathBuf::from("/"), 28);
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);
@@ -597,7 +745,14 @@ mod tests {
},
);
let lines = create_diff_summary(&changes, &cwd, 80);
let lines = create_diff_summary(
&changes,
PatchEventType::ApplyBegin {
auto_approved: true,
},
&cwd,
80,
);
snapshot_lines("apply_update_block_relativizes_path", lines, 80, 10);
}

View File

@@ -5,6 +5,7 @@ use crate::exec_cell::TOOL_CALL_MAX_LINES;
use crate::exec_cell::output_lines;
use crate::exec_cell::spinner;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::markdown::MarkdownCitationContext;
use crate::markdown::append_markdown;
use crate::render::line_utils::line_to_static;
@@ -49,6 +50,12 @@ use std::time::Instant;
use tracing::error;
use unicode_width::UnicodeWidthStr;
#[derive(Clone, Debug)]
pub(crate) enum PatchEventType {
ApprovalRequest,
ApplyBegin { auto_approved: bool },
}
/// Represents an event to display in the conversation history. Returns its
/// `Vec<Line<'static>>` representation to make it easier to display in a
/// scrollable list.
@@ -270,13 +277,19 @@ pub(crate) fn new_review_status_line(message: String) -> PlainHistoryCell {
#[derive(Debug)]
pub(crate) struct PatchHistoryCell {
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>,
cwd: PathBuf,
}
impl HistoryCell for PatchHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
create_diff_summary(&self.changes, &self.cwd, width as usize)
create_diff_summary(
&self.changes,
self.event_type.clone(),
&self.cwd,
width as usize,
)
}
}
@@ -1003,10 +1016,12 @@ impl HistoryCell for PlanUpdateCell {
/// a proposed patch. The summary lines should already be formatted (e.g.
/// "A path/to/file.rs").
pub(crate) fn new_patch_event(
event_type: PatchEventType,
changes: HashMap<PathBuf, FileChange>,
cwd: &Path,
) -> PatchHistoryCell {
PatchHistoryCell {
event_type,
changes,
cwd: cwd.to_path_buf(),
}
@@ -1037,6 +1052,27 @@ pub(crate) fn new_patch_apply_failure(stderr: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}
/// Create a new history cell for a proposed command approval.
/// Renders a header and the command preview similar to how proposed patches
/// show a header and summary.
pub(crate) fn new_proposed_command(command: &[String]) -> PlainHistoryCell {
let cmd = strip_bash_lc_and_escape(command);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec!["".dim(), "Proposed Command".bold()]));
let highlighted_lines = crate::render::highlight::highlight_bash_to_lines(&cmd);
let initial_prefix: Span<'static> = "".dim();
let subsequent_prefix: Span<'static> = " ".into();
lines.extend(prefix_lines(
highlighted_lines,
initial_prefix,
subsequent_prefix,
));
PlainHistoryCell { lines }
}
pub(crate) fn new_reasoning_block(
full_reasoning_buffer: String,
config: &Config,

View File

@@ -3,14 +3,13 @@ use std::sync::Arc;
use std::time::Duration;
use crate::history_cell::HistoryCell;
use crate::render::renderable::Renderable;
use crate::render::line_utils::push_owned_lines;
use crate::tui;
use crate::tui::TuiEvent;
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyEventKind;
use ratatui::buffer::Buffer;
use ratatui::buffer::Cell;
use ratatui::layout::Rect;
use ratatui::style::Color;
use ratatui::style::Style;
@@ -23,7 +22,6 @@ use ratatui::widgets::Clear;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use ratatui::widgets::WidgetRef;
use ratatui::widgets::Wrap;
pub(crate) enum Overlay {
Transcript(TranscriptOverlay),
@@ -35,17 +33,10 @@ impl Overlay {
Self::Transcript(TranscriptOverlay::new(cells))
}
pub(crate) fn new_static_with_lines(lines: Vec<Line<'static>>, title: String) -> Self {
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self::Static(StaticOverlay::with_title(lines, title))
}
pub(crate) fn new_static_with_renderables(
renderables: Vec<Box<dyn Renderable>>,
title: String,
) -> Self {
Self::Static(StaticOverlay::with_renderables(renderables, title))
}
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
match self {
Overlay::Transcript(o) => o.handle_event(tui, event),
@@ -87,53 +78,57 @@ fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
/// Generic widget for rendering a pager view.
struct PagerView {
renderables: Vec<Box<dyn Renderable>>,
texts: Vec<Text<'static>>,
scroll_offset: usize,
title: String,
wrap_cache: Option<WrapCache>,
last_content_height: Option<usize>,
last_rendered_height: Option<usize>,
/// If set, on next render ensure this chunk is visible.
pending_scroll_chunk: Option<usize>,
}
impl PagerView {
fn new(renderables: Vec<Box<dyn Renderable>>, title: String, scroll_offset: usize) -> Self {
fn new(texts: Vec<Text<'static>>, title: String, scroll_offset: usize) -> Self {
Self {
renderables,
texts,
scroll_offset,
title,
wrap_cache: None,
last_content_height: None,
last_rendered_height: None,
pending_scroll_chunk: None,
}
}
fn content_height(&self, width: u16) -> usize {
self.renderables
.iter()
.map(|c| c.desired_height(width) as usize)
.sum()
}
fn render(&mut self, area: Rect, buf: &mut Buffer) {
Clear.render(area, buf);
self.render_header(area, buf);
let content_area = self.content_area(area);
let content_area = self.scroll_area(area);
self.update_last_content_height(content_area.height);
let content_height = self.content_height(content_area.width);
self.last_rendered_height = Some(content_height);
self.ensure_wrapped(content_area.width);
// If there is a pending request to scroll a specific chunk into view,
// satisfy it now that wrapping is up to date for this width.
if let Some(idx) = self.pending_scroll_chunk.take() {
self.ensure_chunk_visible(idx, content_area);
if let (Some(idx), Some(cache)) =
(self.pending_scroll_chunk.take(), self.wrap_cache.as_ref())
&& let Some(range) = cache.chunk_ranges.get(idx).cloned()
{
self.ensure_range_visible(range, content_area.height as usize, cache.wrapped.len());
}
// Compute page bounds without holding an immutable borrow on cache while mutating self
let wrapped_len = self
.wrap_cache
.as_ref()
.map(|c| c.wrapped.len())
.unwrap_or(0);
self.scroll_offset = self
.scroll_offset
.min(content_height.saturating_sub(content_area.height as usize));
.min(wrapped_len.saturating_sub(content_area.height as usize));
let start = self.scroll_offset;
let end = (start + content_area.height as usize).min(wrapped_len);
self.render_content(content_area, buf);
self.render_bottom_bar(area, content_area, buf, content_height);
let wrapped = self.cached();
let page = &wrapped[start..end];
self.render_content_page_prepared(content_area, buf, page);
self.render_bottom_bar(area, content_area, buf, wrapped);
}
fn render_header(&self, area: Rect, buf: &mut Buffer) {
@@ -144,38 +139,20 @@ impl PagerView {
header.dim().render_ref(area, buf);
}
fn render_content(&self, area: Rect, buf: &mut Buffer) {
let mut y = -(self.scroll_offset as isize);
let mut drawn_bottom = area.y;
for renderable in &self.renderables {
let top = y;
let height = renderable.desired_height(area.width) as isize;
y += height;
let bottom = y;
if bottom < area.y as isize {
continue;
}
if top > area.y as isize + area.height as isize {
break;
}
if top < 0 {
let drawn = render_offset_content(area, buf, &**renderable, (-top) as u16);
drawn_bottom = drawn_bottom.max(area.y + drawn);
} else {
let draw_height = (height as u16).min(area.height.saturating_sub(top as u16));
let draw_area = Rect::new(area.x, area.y + top as u16, area.width, draw_height);
renderable.render(draw_area, buf);
drawn_bottom = drawn_bottom.max(draw_area.y.saturating_add(draw_area.height));
}
}
// Removed unused render_content_page (replaced by render_content_page_prepared)
for y in drawn_bottom..area.bottom() {
if area.width == 0 {
break;
}
buf[(area.x, y)] = Cell::from('~');
for x in area.x + 1..area.right() {
buf[(x, y)] = Cell::from(' ');
fn render_content_page_prepared(&self, area: Rect, buf: &mut Buffer, page: &[Line<'static>]) {
Clear.render(area, buf);
Paragraph::new(page.to_vec()).render_ref(area, buf);
let visible = page.len();
if visible < area.height as usize {
for i in 0..(area.height as usize - visible) {
let add = ((visible + i).min(u16::MAX as usize)) as u16;
let y = area.y.saturating_add(add);
Span::from("~")
.dim()
.render_ref(Rect::new(area.x, y, 1, 1), buf);
}
}
}
@@ -185,7 +162,7 @@ impl PagerView {
full_area: Rect,
content_area: Rect,
buf: &mut Buffer,
total_len: usize,
wrapped: &[Line<'static>],
) {
let sep_y = content_area.bottom();
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, 1);
@@ -193,10 +170,10 @@ impl PagerView {
Span::from("".repeat(sep_rect.width as usize))
.dim()
.render_ref(sep_rect, buf);
let percent = if total_len == 0 {
let percent = if wrapped.is_empty() {
100
} else {
let max_scroll = total_len.saturating_sub(content_area.height as usize);
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
if max_scroll == 0 {
100
} else {
@@ -233,7 +210,7 @@ impl PagerView {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.content_area(tui.terminal.viewport_area);
let area = self.scroll_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
}
KeyEvent {
@@ -241,7 +218,7 @@ impl PagerView {
kind: KeyEventKind::Press | KeyEventKind::Repeat,
..
} => {
let area = self.content_area(tui.terminal.viewport_area);
let area = self.scroll_area(tui.terminal.viewport_area);
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
}
KeyEvent {
@@ -271,7 +248,7 @@ impl PagerView {
self.last_content_height = Some(height as usize);
}
fn content_area(&self, area: Rect) -> Rect {
fn scroll_area(&self, area: Rect) -> Rect {
let mut area = area;
area.y = area.y.saturating_add(1);
area.height = area.height.saturating_sub(2);
@@ -279,24 +256,67 @@ impl PagerView {
}
}
#[derive(Debug, Clone)]
struct WrapCache {
width: u16,
wrapped: Vec<Line<'static>>,
/// For each input Text chunk, the inclusive-excluded range of wrapped lines produced.
chunk_ranges: Vec<std::ops::Range<usize>>,
base_len: usize,
}
impl PagerView {
fn ensure_wrapped(&mut self, width: u16) {
let width = width.max(1);
let needs = match self.wrap_cache {
Some(ref c) => c.width != width || c.base_len != self.texts.len(),
None => true,
};
if !needs {
return;
}
let mut wrapped: Vec<Line<'static>> = Vec::new();
let mut chunk_ranges: Vec<std::ops::Range<usize>> = Vec::with_capacity(self.texts.len());
for text in &self.texts {
let start = wrapped.len();
for line in &text.lines {
let ws = crate::wrapping::word_wrap_line(line, width as usize);
push_owned_lines(&ws, &mut wrapped);
}
let end = wrapped.len();
chunk_ranges.push(start..end);
}
self.wrap_cache = Some(WrapCache {
width,
wrapped,
chunk_ranges,
base_len: self.texts.len(),
});
}
fn cached(&self) -> &[Line<'static>] {
if let Some(cache) = self.wrap_cache.as_ref() {
&cache.wrapped
} else {
&[]
}
}
fn is_scrolled_to_bottom(&self) -> bool {
if self.scroll_offset == usize::MAX {
return true;
}
let Some(cache) = &self.wrap_cache else {
return false;
};
let Some(height) = self.last_content_height else {
return false;
};
if self.renderables.is_empty() {
if cache.wrapped.is_empty() {
return true;
}
let Some(total_height) = self.last_rendered_height else {
return false;
};
if total_height <= height {
return true;
}
let max_scroll = total_height.saturating_sub(height);
let visible = height.min(cache.wrapped.len());
let max_scroll = cache.wrapped.len().saturating_sub(visible);
self.scroll_offset >= max_scroll
}
@@ -305,57 +325,32 @@ impl PagerView {
self.pending_scroll_chunk = Some(chunk_index);
}
fn ensure_chunk_visible(&mut self, idx: usize, area: Rect) {
if area.height == 0 || idx >= self.renderables.len() {
fn ensure_range_visible(
&mut self,
range: std::ops::Range<usize>,
viewport_height: usize,
total_wrapped: usize,
) {
if viewport_height == 0 || total_wrapped == 0 {
return;
}
let first = self
.renderables
.iter()
.take(idx)
.map(|r| r.desired_height(area.width) as usize)
.sum();
let last = first + self.renderables[idx].desired_height(area.width) as usize;
let current_top = self.scroll_offset;
let current_bottom = current_top.saturating_add(area.height.saturating_sub(1) as usize);
let first = range.start.min(total_wrapped.saturating_sub(1));
let last = range
.end
.saturating_sub(1)
.min(total_wrapped.saturating_sub(1));
let current_top = self.scroll_offset.min(total_wrapped.saturating_sub(1));
let current_bottom = current_top.saturating_add(viewport_height.saturating_sub(1));
if first < current_top {
self.scroll_offset = first;
} else if last > current_bottom {
self.scroll_offset = last.saturating_sub(area.height.saturating_sub(1) as usize);
// Scroll just enough so that 'last' is visible at the bottom
self.scroll_offset = last.saturating_sub(viewport_height.saturating_sub(1));
}
}
}
struct CachedParagraph {
paragraph: Paragraph<'static>,
height: std::cell::Cell<Option<u16>>,
last_width: std::cell::Cell<Option<u16>>,
}
impl CachedParagraph {
fn new(paragraph: Paragraph<'static>) -> Self {
Self {
paragraph,
height: std::cell::Cell::new(None),
last_width: std::cell::Cell::new(None),
}
}
}
impl Renderable for CachedParagraph {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.paragraph.render_ref(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
if self.last_width.get() != Some(width) {
let height = self.paragraph.line_count(width) as u16;
self.height.set(Some(height));
self.last_width.set(Some(width));
}
self.height.get().unwrap_or(0)
}
}
pub(crate) struct TranscriptOverlay {
view: PagerView,
cells: Vec<Arc<dyn HistoryCell>>,
@@ -380,8 +375,8 @@ impl TranscriptOverlay {
fn render_cells_to_texts(
cells: &[Arc<dyn HistoryCell>],
highlight_cell: Option<usize>,
) -> Vec<Box<dyn Renderable>> {
let mut texts: Vec<Box<dyn Renderable>> = Vec::new();
) -> Vec<Text<'static>> {
let mut texts: Vec<Text<'static>> = Vec::new();
let mut first = true;
for (idx, cell) in cells.iter().enumerate() {
let mut lines: Vec<Line<'static>> = Vec::new();
@@ -397,9 +392,7 @@ impl TranscriptOverlay {
cell.transcript_lines()
};
lines.extend(cell_lines);
texts.push(Box::new(CachedParagraph::new(
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
)));
texts.push(Text::from(lines));
first = false;
}
texts
@@ -413,10 +406,9 @@ impl TranscriptOverlay {
lines.push(Line::from(""));
}
lines.extend(cell.transcript_lines());
self.view.renderables.push(Box::new(CachedParagraph::new(
Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }),
)));
self.view.texts.push(Text::from(lines));
self.cells.push(cell);
self.view.wrap_cache = None;
if follow_bottom {
self.view.scroll_offset = usize::MAX;
}
@@ -424,7 +416,8 @@ impl TranscriptOverlay {
pub(crate) fn set_highlight_cell(&mut self, cell: Option<usize>) {
self.highlight_cell = cell;
self.view.renderables = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
self.view.wrap_cache = None;
self.view.texts = Self::render_cells_to_texts(&self.cells, self.highlight_cell);
if let Some(idx) = self.highlight_cell {
self.view.scroll_chunk_into_view(idx);
}
@@ -497,17 +490,8 @@ pub(crate) struct StaticOverlay {
impl StaticOverlay {
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
Self::with_renderables(
vec![Box::new(CachedParagraph::new(Paragraph::new(Text::from(
lines,
))))],
title,
)
}
pub(crate) fn with_renderables(renderables: Vec<Box<dyn Renderable>>, title: String) -> Self {
Self {
view: PagerView::new(renderables, title, 0),
view: PagerView::new(vec![Text::from(lines)], title, 0),
is_done: false,
}
}
@@ -563,33 +547,6 @@ impl StaticOverlay {
}
}
fn render_offset_content(
area: Rect,
buf: &mut Buffer,
renderable: &dyn Renderable,
scroll_offset: u16,
) -> u16 {
let height = renderable.desired_height(area.width);
let mut tall_buf = Buffer::empty(Rect::new(
0,
0,
area.width,
height.min(area.height + scroll_offset),
));
renderable.render(*tall_buf.area(), &mut tall_buf);
let copy_height = area
.height
.min(tall_buf.area().height.saturating_sub(scroll_offset));
for y in 0..copy_height {
let src_y = y + scroll_offset;
for x in 0..area.width {
buf[(area.x + x, area.y + y)] = tall_buf[(x, src_y)].clone();
}
}
copy_height
}
#[cfg(test)]
mod tests {
use super::*;
@@ -601,12 +558,12 @@ mod tests {
use crate::exec_cell::CommandOutput;
use crate::history_cell::HistoryCell;
use crate::history_cell::PatchEventType;
use crate::history_cell::new_patch_event;
use codex_core::protocol::FileChange;
use codex_protocol::parse_command::ParsedCommand;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::text::Text;
#[derive(Debug)]
struct TestCell {
@@ -623,15 +580,6 @@ mod tests {
}
}
fn paragraph_block(label: &str, lines: usize) -> Box<dyn Renderable> {
let text = Text::from(
(0..lines)
.map(|i| Line::from(format!("{label}{i}")))
.collect::<Vec<_>>(),
);
Box::new(Paragraph::new(text)) as Box<dyn Renderable>
}
#[test]
fn edit_prev_hint_is_visible() {
let mut overlay = TranscriptOverlay::new(vec![Arc::new(TestCell {
@@ -709,7 +657,11 @@ mod tests {
content: "hello\nworld\n".to_string(),
},
);
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(approval_changes, &cwd));
let approval_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
PatchEventType::ApprovalRequest,
approval_changes,
&cwd,
));
cells.push(approval_cell);
let mut apply_changes = HashMap::new();
@@ -719,7 +671,13 @@ mod tests {
content: "hello\nworld\n".to_string(),
},
);
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(apply_changes, &cwd));
let apply_begin_cell: Arc<dyn HistoryCell> = Arc::new(new_patch_event(
PatchEventType::ApplyBegin {
auto_approved: false,
},
apply_changes,
&cwd,
));
cells.push(apply_begin_cell);
let apply_end_cell: Arc<dyn HistoryCell> =
@@ -753,6 +711,7 @@ mod tests {
overlay.render(area, &mut buf);
overlay.view.scroll_offset = 0;
overlay.view.wrap_cache = None;
overlay.render(area, &mut buf);
let snapshot = buffer_to_text(&buf, area);
@@ -824,89 +783,54 @@ mod tests {
}
#[test]
fn pager_view_content_height_counts_renderables() {
let pv = PagerView::new(
vec![paragraph_block("a", 2), paragraph_block("b", 3)],
"T".to_string(),
0,
);
assert_eq!(pv.content_height(80), 5);
}
#[test]
fn pager_view_ensure_chunk_visible_scrolls_down_when_needed() {
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![
paragraph_block("a", 1),
paragraph_block("b", 3),
paragraph_block("c", 3),
],
vec![Text::from(vec![long.into()]), Text::from(vec![long.into()])],
"T".to_string(),
0,
);
let area = Rect::new(0, 0, 20, 8);
pv.scroll_offset = 0;
let content_area = pv.content_area(area);
pv.ensure_chunk_visible(2, content_area);
// Build cache at width 24
pv.ensure_wrapped(24);
let w1 = pv.cached();
assert!(!w1.is_empty(), "expected wrapped output to be non-empty");
let ptr1 = w1.as_ptr();
let mut buf = Buffer::empty(area);
pv.render(area, &mut buf);
let rendered = buffer_to_text(&buf, area);
// Re-run with same width: cache should be reused (pointer stability heuristic)
pv.ensure_wrapped(24);
let w2 = pv.cached();
let ptr2 = w2.as_ptr();
assert_eq!(ptr1, ptr2, "cache should not rebuild for unchanged width");
assert!(
rendered.contains("c0"),
"expected chunk top in view: {rendered:?}"
);
assert!(
rendered.contains("c1"),
"expected chunk middle in view: {rendered:?}"
);
assert!(
rendered.contains("c2"),
"expected chunk bottom in view: {rendered:?}"
// Change width: cache should rebuild and likely produce different length
// Drop immutable borrow before mutating
let prev_len = w2.len();
pv.ensure_wrapped(36);
let w3 = pv.cached();
assert_ne!(
prev_len,
w3.len(),
"wrapped length should change on width change"
);
}
#[test]
fn pager_view_ensure_chunk_visible_scrolls_up_when_needed() {
let mut pv = PagerView::new(
vec![
paragraph_block("a", 2),
paragraph_block("b", 3),
paragraph_block("c", 3),
],
"T".to_string(),
0,
);
let area = Rect::new(0, 0, 20, 3);
pv.scroll_offset = 6;
pv.ensure_chunk_visible(0, area);
assert_eq!(pv.scroll_offset, 0);
}
#[test]
fn pager_view_is_scrolled_to_bottom_accounts_for_wrapped_height() {
let mut pv = PagerView::new(vec![paragraph_block("a", 10)], "T".to_string(), 0);
let area = Rect::new(0, 0, 20, 8);
let mut buf = Buffer::empty(area);
pv.render(area, &mut buf);
fn pager_wrap_cache_invalidates_on_append() {
let long = "Another long line for wrapping behavior verification.";
let mut pv = PagerView::new(vec![Text::from(vec![long.into()])], "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.texts.push(Text::from(vec![long.into()]));
pv.texts.push(Text::from(vec![long.into()]));
pv.ensure_wrapped(28);
let w2 = pv.cached();
assert!(
!pv.is_scrolled_to_bottom(),
"expected view to report not at bottom when offset < max"
);
pv.scroll_offset = usize::MAX;
pv.render(area, &mut buf);
assert!(
pv.is_scrolled_to_bottom(),
"expected view to report at bottom after scrolling to end"
w2.len() >= len1,
"wrapped length should grow or stay same after append"
);
}
}

View File

@@ -1,47 +1,2 @@
use ratatui::layout::Rect;
pub mod highlight;
pub mod line_utils;
pub mod renderable;
pub struct Insets {
pub left: u16,
pub top: u16,
pub right: u16,
pub bottom: u16,
}
impl Insets {
pub fn tlbr(top: u16, left: u16, bottom: u16, right: u16) -> Self {
Self {
top,
left,
bottom,
right,
}
}
pub fn vh(v: u16, h: u16) -> Self {
Self {
top: v,
left: h,
bottom: v,
right: h,
}
}
}
pub trait RectExt {
fn inset(&self, insets: Insets) -> Rect;
}
impl RectExt for Rect {
fn inset(&self, insets: Insets) -> Rect {
Rect {
x: self.x + insets.left,
y: self.y + insets.top,
width: self.width - insets.left - insets.right,
height: self.height - insets.top - insets.bottom,
}
}
}

View File

@@ -1,102 +0,0 @@
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Paragraph;
use ratatui::widgets::WidgetRef;
pub trait Renderable {
fn render(&self, area: Rect, buf: &mut Buffer);
fn desired_height(&self, width: u16) -> u16;
}
impl Renderable for () {
fn render(&self, _area: Rect, _buf: &mut Buffer) {}
fn desired_height(&self, _width: u16) -> u16 {
0
}
}
impl Renderable for &str {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
impl Renderable for String {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
impl<'a> Renderable for Line<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
WidgetRef::render_ref(self, area, buf);
}
fn desired_height(&self, _width: u16) -> u16 {
1
}
}
impl<'a> Renderable for Paragraph<'a> {
fn render(&self, area: Rect, buf: &mut Buffer) {
self.render_ref(area, buf);
}
fn desired_height(&self, width: u16) -> u16 {
self.line_count(width) as u16
}
}
impl<R: Renderable> Renderable for Option<R> {
fn render(&self, area: Rect, buf: &mut Buffer) {
if let Some(renderable) = self {
renderable.render(area, buf);
}
}
fn desired_height(&self, width: u16) -> u16 {
if let Some(renderable) = self {
renderable.desired_height(width)
} else {
0
}
}
}
pub struct ColumnRenderable {
children: Vec<Box<dyn Renderable>>,
}
impl Renderable for ColumnRenderable {
fn render(&self, area: Rect, buf: &mut Buffer) {
let mut y = area.y;
for child in &self.children {
let child_area = Rect::new(area.x, y, area.width, child.desired_height(area.width))
.intersection(area);
if !child_area.is_empty() {
child.render(child_area, buf);
}
y += child_area.height;
}
}
fn desired_height(&self, width: u16) -> u16 {
self.children
.iter()
.map(|child| child.desired_height(width))
.sum()
}
}
impl ColumnRenderable {
pub fn new(children: impl IntoIterator<Item = Box<dyn Renderable>>) -> Self {
Self {
children: children.into_iter().collect(),
}
}
}

View File

@@ -2,11 +2,11 @@
source: tui/src/diff_render.rs
expression: terminal.backend()
---
"• Edited example.txt (+1 -1) "
" 1 line one "
" 2 -line two "
" 2 +line two changed "
" 3 line three "
"• Change Approved example.txt (+1 -1) "
" "
" "
" "
" "
" "
" "
" "

View File

@@ -1,15 +1,16 @@
---
source: tui/src/pager_overlay.rs
assertion_line: 721
expression: snapshot
---
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
Added foo.txt (+2 -0)
Proposed Change foo.txt (+2 -0)
1 +hello
2 +world
Added foo.txt (+2 -0)
1 +hello
2 +world
Change Approved foo.txt (+2 -0)
✓ Patch applied
─────────────────────────────────────────────────────────────────────────── 0% ─
↑/↓ scroll PgUp/PgDn page Home/End jump
q quit Esc edit prev

View File

@@ -11,10 +11,6 @@ use unicode_width::UnicodeWidthStr;
use super::account::StatusAccountDisplay;
fn normalize_agents_display_path(path: &Path) -> String {
dunce::simplified(path).display().to_string()
}
pub(crate) fn compose_model_display(
config: &Config,
entries: &[(&str, String)],
@@ -40,13 +36,9 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String {
Ok(paths) => {
let mut rels: Vec<String> = Vec::new();
for p in paths {
let file_name = p
.file_name()
.map(|name| name.to_string_lossy().to_string())
.unwrap_or_else(|| "<unknown>".to_string());
let display = if let Some(parent) = p.parent() {
if parent == config.cwd {
file_name.clone()
"AGENTS.md".to_string()
} else {
let mut cur = config.cwd.as_path();
let mut ups = 0usize;
@@ -61,15 +53,15 @@ pub(crate) fn compose_agents_summary(config: &Config) -> String {
}
if reached {
let up = format!("..{}", std::path::MAIN_SEPARATOR);
format!("{}{}", up.repeat(ups), file_name)
format!("{}AGENTS.md", up.repeat(ups))
} else if let Ok(stripped) = p.strip_prefix(&config.cwd) {
normalize_agents_display_path(stripped)
stripped.display().to_string()
} else {
normalize_agents_display_path(&p)
p.display().to_string()
}
}
} else {
normalize_agents_display_path(&p)
p.display().to_string()
};
rels.push(display);
}

View File

@@ -17,6 +17,7 @@ use crate::app_event_sender::AppEventSender;
use crate::key_hint;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use crate::ui_consts::LIVE_PREFIX_COLS;
pub(crate) struct StatusIndicatorWidget {
/// Animated header text (defaults to "Working").
@@ -159,7 +160,7 @@ impl WidgetRef for StatusIndicatorWidget {
let pretty_elapsed = fmt_elapsed_compact(elapsed);
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
let mut spans = vec![" ".into()];
let mut spans = vec![" ".repeat(LIVE_PREFIX_COLS as usize).into()];
spans.extend(shimmer_spans(&self.header));
spans.extend(vec![
" ".into(),

View File

@@ -705,16 +705,6 @@ This is analogous to `model_context_window`, but for the maximum number of outpu
Maximum number of bytes to read from an `AGENTS.md` file to include in the instructions sent with the first turn of a session. Defaults to 32 KiB.
## project_doc_fallback_filenames
Ordered list of additional filenames to look for when `AGENTS.md` is missing at a given directory level. The CLI always checks `AGENTS.md` first; the configured fallbacks are tried in the order provided. This lets monorepos that already use alternate instruction files (for example, `CLAUDE.md`) work out of the box while you migrate to `AGENTS.md` over time.
```toml
project_doc_fallback_filenames = ["CLAUDE.md", ".exampleagentrules.md"]
```
We recommend migrating instructions to AGENTS.md; other filenames may reduce model performance.
## tui
Options that are specific to the TUI.

13
pnpm-lock.yaml generated
View File

@@ -34,9 +34,6 @@ importers:
eslint-plugin-jest:
specifier: ^29.0.1
version: 29.0.1(@typescript-eslint/eslint-plugin@8.45.0(@typescript-eslint/parser@8.45.0(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(typescript@5.9.2))(eslint@9.36.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)
eslint-plugin-node-import:
specifier: ^1.0.5
version: 1.0.5(eslint@9.36.0)
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2))
@@ -1087,12 +1084,6 @@ packages:
jest:
optional: true
eslint-plugin-node-import@1.0.5:
resolution: {integrity: sha512-razzgbr3EcB5+bm8/gqTqzTJ7Bpiu8PIChiAMRfZCNigr9GZBtnVSI+wPw+RGbWYCCIzWAsK/A7ihoAeSz5j7A==}
engines: {node: ^14.18.0 || ^16.0.0 || >= 18.0.0}
peerDependencies:
eslint: '>=7'
eslint-scope@8.4.0:
resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
@@ -3253,10 +3244,6 @@ snapshots:
- supports-color
- typescript
eslint-plugin-node-import@1.0.5(eslint@9.36.0):
dependencies:
eslint: 9.36.0
eslint-scope@8.4.0:
dependencies:
esrecurse: 4.3.0

View File

@@ -1,53 +1 @@
# Codex SDK
Bring the power of the best coding agent to your application.
## Installation
```bash
npm install @openai/codex-sdk
```
## Usage
Call `startThread()` and `run()` to start a thead with Codex.
```typescript
import { Codex } from "@openai/codex-sdk";
const codex = new Codex();
const thread = codex.startThread();
const result = await thread.run("Diagnose the test failure and propose a fix");
console.log(result);
```
You can call `run()` again to continue the same thread.
```typescript
const result = await thread.run("Implement the fix");
console.log(result);
```
### Streaming
The `await run()` method completes when a thread turn is complete and agent is prepared the final response.
You can thread items while they are being produced by calling `await runStreamed()`.
```typescript
const result = thread.runStreamed("Diagnose the test failure and propose a fix");
```
### Resuming a thread
If you don't have the original `Thread` instance to continue the thread, you can resume a thread by calling `resumeThread()` and providing the thread.
```typescript
const threadId = "...";
const thread = codex.resumeThread(threadId);
const result = await thread.run("Implement the fix");
console.log(result);
```

View File

@@ -1,14 +1,8 @@
import eslint from "@eslint/js";
import { defineConfig } from "eslint/config";
import tseslint from "typescript-eslint";
import nodeImport from "eslint-plugin-node-import";
import eslint from '@eslint/js';
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
export default defineConfig(eslint.configs.recommended, tseslint.configs.recommended, {
plugins: {
"node-import": nodeImport,
},
rules: {
"node-import/prefer-node-protocol": 2,
},
});
export default defineConfig(
eslint.configs.recommended,
tseslint.configs.recommended,
);

View File

@@ -50,14 +50,13 @@
"eslint": "^9.36.0",
"eslint-config-prettier": "^9.1.2",
"eslint-plugin-jest": "^29.0.1",
"eslint-plugin-node-import": "^1.0.5",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.3.4",
"ts-jest-mock-import-meta": "^1.3.1",
"ts-node": "^10.9.2",
"tsup": "^8.5.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.45.0"
"typescript-eslint": "^8.45.0",
"ts-jest-mock-import-meta": "^1.3.1"
}
}

View File

@@ -7,11 +7,11 @@ import { Codex } from "@openai/codex-sdk";
import type { ThreadEvent, ThreadItem } from "@openai/codex-sdk";
import path from "node:path";
const codexPathOverride =
const executablePath =
process.env.CODEX_EXECUTABLE ??
path.join(process.cwd(), "..", "..", "codex-rs", "target", "debug", "codex");
const codex = new Codex({ codexPathOverride });
const codex = new Codex({ executablePath });
const thread = codex.startThread();
const rl = createInterface({ input, output });

View File

@@ -2,35 +2,19 @@ import { CodexOptions } from "./codexOptions";
import { CodexExec } from "./exec";
import { Thread } from "./thread";
/**
* Codex is the main class for interacting with the Codex agent.
*
* Use the `startThread()` method to start a new thread or `resumeThread()` to resume a previously started thread.
*/
export class Codex {
private exec: CodexExec;
private options: CodexOptions;
constructor(options: CodexOptions = {}) {
constructor(options: CodexOptions) {
this.exec = new CodexExec(options.codexPathOverride);
this.options = options;
}
/**
* Starts a new conversation with an agent.
* @returns A new thread instance.
*/
startThread(): Thread {
return new Thread(this.exec, this.options);
}
/**
* Resumes a conversation with an agent based on the thread id.
* Threads are persisted in ~/.codex/sessions.
*
* @param id The id of the thread to resume.
* @returns A new thread instance.
*/
resumeThread(id: string): Thread {
return new Thread(this.exec, this.options, id);
}

View File

@@ -2,5 +2,4 @@ export type CodexOptions = {
codexPathOverride?: string;
baseUrl?: string;
apiKey?: string;
workingDirectory?: string;
};

View File

@@ -2,73 +2,55 @@
import type { ThreadItem } from "./items";
/** Emitted when a new thread is started as the first event. */
export type ThreadStartedEvent = {
type: "thread.started";
/** The identifier of the new thread. Can be used to resume the thread later. */
thread_id: string;
};
/**
* Emitted when a turn is started by sending a new prompt to the model.
* A turn encompasses all events that happen while the agent is processing the prompt.
*/
export type TurnStartedEvent = {
type: "turn.started";
};
/** Describes the usage of tokens during a turn. */
export type Usage = {
/** The number of input tokens used during the turn. */
input_tokens: number;
/** The number of cached input tokens used during the turn. */
cached_input_tokens: number;
/** The number of output tokens used during the turn. */
output_tokens: number;
};
/** Emitted when a turn is completed. Typically right after the assistant's response. */
export type TurnCompletedEvent = {
type: "turn.completed";
usage: Usage;
};
/** Indicates that a turn failed with an error. */
export type TurnFailedEvent = {
type: "turn.failed";
error: ThreadError;
};
/** Emitted when a new item is added to the thread. Typically the item is initially "in progress". */
export type ItemStartedEvent = {
type: "item.started";
item: ThreadItem;
};
/** Emitted when an item is updated. */
export type ItemUpdatedEvent = {
type: "item.updated";
item: ThreadItem;
};
/** Signals that an item has reached a terminal state—either success or failure. */
export type ItemCompletedEvent = {
type: "item.completed";
item: ThreadItem;
};
/** Fatal error emitted by the stream. */
export type ThreadError = {
message: string;
};
/** Represents an unrecoverable error emitted directly by the event stream. */
export type ThreadErrorEvent = {
type: "error";
message: string;
};
/** Top-level JSONL events emitted by codex exec. */
export type ThreadEvent =
| ThreadStartedEvent
| TurnStartedEvent

View File

@@ -1,4 +1,4 @@
import { spawn } from "node:child_process";
import { spawn } from "child_process";
import readline from "node:readline";
@@ -12,14 +12,8 @@ export type CodexExecArgs = {
baseUrl?: string;
apiKey?: string;
threadId?: string | null;
// --model
model?: string;
// --sandbox
sandboxMode?: SandboxMode;
// --cd
workingDirectory?: string;
// --skip-git-repo-check
skipGitRepoCheck?: boolean;
};
export class CodexExec {
@@ -39,16 +33,10 @@ export class CodexExec {
commandArgs.push("--sandbox", args.sandboxMode);
}
if (args.workingDirectory) {
commandArgs.push("--cd", args.workingDirectory);
}
if (args.skipGitRepoCheck) {
commandArgs.push("--skip-git-repo-check");
}
if (args.threadId) {
commandArgs.push("resume", args.threadId);
commandArgs.push("resume", args.threadId, args.input);
} else {
commandArgs.push(args.input);
}
const env = {
@@ -68,24 +56,10 @@ export class CodexExec {
let spawnError: unknown | null = null;
child.once("error", (err) => (spawnError = err));
if (!child.stdin) {
child.kill();
throw new Error("Child process has no stdin");
}
child.stdin.write(args.input);
child.stdin.end();
if (!child.stdout) {
child.kill();
throw new Error("Child process has no stdout");
}
const stderrChunks: Buffer[] = [];
if (child.stderr) {
child.stderr.on("data", (data) => {
stderrChunks.push(data);
});
}
const rl = readline.createInterface({
input: child.stdout,
@@ -98,15 +72,12 @@ export class CodexExec {
yield line as string;
}
const exitCode = new Promise((resolve, reject) => {
child.once("exit", (code) => {
const exitCode = new Promise((resolve) => {
child.once("exit", (code) => {
if (code === 0) {
resolve(code);
} else {
const stderrBuffer = Buffer.concat(stderrChunks);
reject(
new Error(`Codex Exec exited with code ${code}: ${stderrBuffer.toString("utf8")}`),
);
throw new Error(`Codex Exec exited with code ${code}`);
}
});
});

View File

@@ -22,8 +22,7 @@ export type {
ErrorItem,
} from "./items";
export { Thread } from "./thread";
export type { RunResult, RunStreamedResult, Input } from "./thread";
export { Thread, RunResult, RunStreamedResult, Input } from "./thread";
export { Codex } from "./codex";

View File

@@ -1,101 +1,71 @@
// based on item types from codex-rs/exec/src/exec_events.rs
/** The status of a command execution. */
export type CommandExecutionStatus = "in_progress" | "completed" | "failed";
/** A command executed by the agent. */
export type CommandExecutionItem = {
id: string;
item_type: "command_execution";
/** The command line executed by the agent. */
command: string;
/** Aggregated stdout and stderr captured while the command was running. */
aggregated_output: string;
/** Set when the command exits; omitted while still running. */
exit_code?: number;
/** Current status of the command execution. */
status: CommandExecutionStatus;
};
/** Indicates the type of the file change. */
export type PatchChangeKind = "add" | "delete" | "update";
/** A set of file changes by the agent. */
export type FileUpdateChange = {
path: string;
kind: PatchChangeKind;
};
/** The status of a file change. */
export type PatchApplyStatus = "completed" | "failed";
/** A set of file changes by the agent. Emitted once the patch succeeds or fails. */
export type FileChangeItem = {
id: string;
item_type: "file_change";
/** Individual file changes that comprise the patch. */
changes: FileUpdateChange[];
/** Whether the patch ultimately succeeded or failed. */
status: PatchApplyStatus;
};
/** The status of an MCP tool call. */
export type McpToolCallStatus = "in_progress" | "completed" | "failed";
/**
* Represents a call to an MCP tool. The item starts when the invocation is dispatched
* and completes when the MCP server reports success or failure.
*/
export type McpToolCallItem = {
id: string;
item_type: "mcp_tool_call";
/** Name of the MCP server handling the request. */
server: string;
/** The tool invoked on the MCP server. */
tool: string;
/** Current status of the tool invocation. */
status: McpToolCallStatus;
};
/** Response from the agent. Either natural-language text or JSON when structured output is requested. */
export type AssistantMessageItem = {
id: string;
item_type: "assistant_message";
/** Either natural-language text or JSON when structured output is requested. */
text: string;
};
/** Agent's reasoning summary. */
export type ReasoningItem = {
id: string;
item_type: "reasoning";
text: string;
};
/** Captures a web search request. Completes when results are returned to the agent. */
export type WebSearchItem = {
id: string;
item_type: "web_search";
query: string;
};
/** Describes a non-fatal error surfaced as an item. */
export type ErrorItem = {
id: string;
item_type: "error";
message: string;
};
/** An item in the agent's to-do list. */
export type TodoItem = {
text: string;
completed: boolean;
};
/**
* Tracks the agent's running to-do list. Starts when the plan is issued, updates as steps change,
* and completes when the turn ends.
*/
export type TodoListItem = {
id: string;
item_type: "todo_list";
@@ -108,7 +78,6 @@ export type SessionItem = {
session_id: string;
};
/** Canonical union of thread items and their type-specific payloads. */
export type ThreadItem =
| AssistantMessageItem
| ReasoningItem

View File

@@ -4,45 +4,29 @@ import { CodexExec } from "./exec";
import { ThreadItem } from "./items";
import { TurnOptions } from "./turnOptions";
/** Completed turn. */
export type Turn = {
export type RunResult = {
items: ThreadItem[];
finalResponse: string;
};
/** Alias for `Turn` to describe the result of `run()`. */
export type RunResult = Turn;
/** The result of the `runStreamed` method. */
export type StreamedTurn = {
export type RunStreamedResult = {
events: AsyncGenerator<ThreadEvent>;
};
/** Alias for `StreamedTurn` to describe the result of `runStreamed()`. */
export type RunStreamedResult = StreamedTurn;
/** An input to send to the agent. */
export type Input = string;
/** Respesent a thread of conversation with the agent. One thread can have multiple consecutive turns. */
export class Thread {
private _exec: CodexExec;
private _options: CodexOptions;
private _id: string | null;
/** Returns the ID of the thread. Populated after the first turn starts. */
public get id(): string | null {
return this._id;
}
private exec: CodexExec;
private options: CodexOptions;
public id: string | null;
constructor(exec: CodexExec, options: CodexOptions, id: string | null = null) {
this._exec = exec;
this._options = options;
this._id = id;
this.exec = exec;
this.options = options;
this.id = id;
}
/** Provides the input to the agent and streams events as they are produced during the turn. */
async runStreamed(input: string, options?: TurnOptions): Promise<StreamedTurn> {
async runStreamed(input: string, options?: TurnOptions): Promise<RunStreamedResult> {
return { events: this.runStreamedInternal(input, options) };
}
@@ -50,32 +34,24 @@ export class Thread {
input: string,
options?: TurnOptions,
): AsyncGenerator<ThreadEvent> {
const generator = this._exec.run({
const generator = this.exec.run({
input,
baseUrl: this._options.baseUrl,
apiKey: this._options.apiKey,
threadId: this._id,
baseUrl: this.options.baseUrl,
apiKey: this.options.apiKey,
threadId: this.id,
model: options?.model,
sandboxMode: options?.sandboxMode,
workingDirectory: options?.workingDirectory,
skipGitRepoCheck: options?.skipGitRepoCheck,
});
for await (const item of generator) {
let parsed: ThreadEvent;
try {
parsed = JSON.parse(item) as ThreadEvent;
} catch (error) {
throw new Error(`Failed to parse item: ${item}`, { cause: error });
}
const parsed = JSON.parse(item) as ThreadEvent;
if (parsed.type === "thread.started") {
this._id = parsed.thread_id;
this.id = parsed.thread_id;
}
yield parsed;
}
}
/** Provides the input to the agent and returns the completed turn. */
async run(input: string, options?: TurnOptions): Promise<Turn> {
async run(input: string, options?: TurnOptions): Promise<RunResult> {
const generator = this.runStreamedInternal(input, options);
const items: ThreadItem[] = [];
let finalResponse: string = "";

View File

@@ -5,6 +5,4 @@ export type SandboxMode = "read-only" | "workspace-write" | "danger-full-access"
export type TurnOptions = {
model?: string;
sandboxMode?: SandboxMode;
workingDirectory?: string;
skipGitRepoCheck?: boolean;
};

View File

@@ -1,15 +1,16 @@
import * as child_process from "node:child_process";
import * as child_process from "child_process";
jest.mock("node:child_process", () => {
const actual = jest.requireActual<typeof import("node:child_process")>("node:child_process");
jest.mock("child_process", () => {
const actual = jest.requireActual<typeof import("child_process")>("child_process");
return { ...actual, spawn: jest.fn(actual.spawn) };
});
const actualChildProcess = jest.requireActual<typeof import("node:child_process")>("node:child_process");
const actualChildProcess = jest.requireActual<typeof import("child_process")>("child_process");
const spawnMock = child_process.spawn as jest.MockedFunction<typeof actualChildProcess.spawn>;
export function codexExecSpy(): { args: string[][]; restore: () => void } {
const previousImplementation = spawnMock.getMockImplementation() ?? actualChildProcess.spawn;
const previousImplementation =
spawnMock.getMockImplementation() ?? actualChildProcess.spawn;
const args: string[][] = [];
spawnMock.mockImplementation(((...spawnArgs: Parameters<typeof child_process.spawn>) => {

View File

@@ -1,6 +1,4 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import path from "path";
import { codexExecSpy } from "./codexExecSpy";
import { describe, expect, it } from "@jest/globals";
@@ -213,78 +211,15 @@ describe("Codex", () => {
expectPair(commandArgs, ["--sandbox", "workspace-write"]);
expectPair(commandArgs, ["--model", "gpt-test-1"]);
} finally {
restore();
await close();
}
});
it("runs in provided working directory", async () => {
const { url, close } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("Working directory applied", "item_1"),
responseCompleted("response_1"),
),
],
});
const { args: spawnArgs, restore } = codexExecSpy();
try {
const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-"));
const client = new Codex({
codexPathOverride: codexExecPath,
baseUrl: url,
apiKey: "test",
});
const thread = client.startThread();
await thread.run("use custom working directory", {
workingDirectory,
skipGitRepoCheck: true,
});
const commandArgs = spawnArgs[0];
expectPair(commandArgs, ["--cd", workingDirectory]);
} finally {
restore();
await close();
}
});
it("throws if working directory is not git and no skipGitRepoCheck is provided", async () => {
const { url, close } = await startResponsesTestProxy({
statusCode: 200,
responseBodies: [
sse(
responseStarted("response_1"),
assistantMessage("Working directory applied", "item_1"),
responseCompleted("response_1"),
),
],
});
try {
const workingDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "codex-working-dir-"));
const client = new Codex({
codexPathOverride: codexExecPath,
baseUrl: url,
apiKey: "test",
});
const thread = client.startThread();
await expect(
thread.run("use custom working directory", {
workingDirectory,
}),
).rejects.toThrow(/Not inside a trusted directory/);
} finally {
await close();
}
});
});
function expectPair(args: string[] | undefined, pair: [string, string]) {
if (!args) {
throw new Error("Args is undefined");

View File

@@ -1,4 +1,4 @@
import path from "node:path";
import path from "path";
import { describe, expect, it } from "@jest/globals";

View File

@@ -19,6 +19,6 @@
"outDir": "dist",
"stripInternal": true
},
"include": ["src", "tests", "tsup.config.ts", "samples"],
"include": ["src", "tests", "tsup.config.ts"],
"exclude": ["dist", "node_modules"]
}