mirror of
https://github.com/openai/codex.git
synced 2026-02-05 08:23:41 +00:00
Compare commits
3 Commits
plan-defau
...
plan-misma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ac36e67af | ||
|
|
11c28a319b | ||
|
|
7b5b4e08a0 |
10
codex-rs/Cargo.lock
generated
10
codex-rs/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -11,7 +11,6 @@ pub const JSONRPC_VERSION: &str = "2.0";
|
||||
#[serde(untagged)]
|
||||
pub enum RequestId {
|
||||
String(String),
|
||||
#[ts(type = "number")]
|
||||
Integer(i64),
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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_http’s 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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&proposed_multi)
|
||||
---
|
||||
• Proposed Command
|
||||
└ echo line1
|
||||
echo line2
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: lines_to_single_string(&proposed)
|
||||
---
|
||||
• Proposed Command
|
||||
└ echo hello world
|
||||
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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 "
|
||||
" "
|
||||
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
" "
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
13
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
@@ -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);
|
||||
```
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -2,5 +2,4 @@ export type CodexOptions = {
|
||||
codexPathOverride?: string;
|
||||
baseUrl?: string;
|
||||
apiKey?: string;
|
||||
workingDirectory?: string;
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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>) => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import path from "node:path";
|
||||
import path from "path";
|
||||
|
||||
import { describe, expect, it } from "@jest/globals";
|
||||
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user