mirror of
https://github.com/openai/codex.git
synced 2026-09-16 12:13:30 +00:00
Merge 7b6fb225e5 into sapling-pr-archive-bolinfest
This commit is contained in:
16
.github/actions/codex/bun.lock
vendored
16
.github/actions/codex/bun.lock
vendored
@@ -8,8 +8,8 @@
|
||||
"@actions/github": "^6.0.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.11",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/bun": "^1.2.18",
|
||||
"@types/node": "^24.0.13",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.8.3",
|
||||
},
|
||||
@@ -48,13 +48,17 @@
|
||||
|
||||
"@octokit/types": ["@octokit/types@13.10.0", "", { "dependencies": { "@octokit/openapi-types": "^24.2.0" } }, "sha512-ifLaO34EbbPj0Xgro4G5lP5asESjwHracYJvVaPIyXMuiuXLlhic3S47cBdTb+jfODkTE5YtGCLt3Ay3+J97sA=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.2.13", "", { "dependencies": { "bun-types": "1.2.13" } }, "sha512-u6vXep/i9VBxoJl3GjZsl/BFIsvML8DfVDO0RYLEwtSZSp981kEO1V5NwRcO1CPJ7AmvpbnDCiMKo3JvbDEjAg=="],
|
||||
"@types/bun": ["@types/bun@1.2.18", "", { "dependencies": { "bun-types": "1.2.18" } }, "sha512-Xf6RaWVheyemaThV0kUfaAUvCNokFr+bH8Jxp+tTZfx7dAPA8z9ePnP9S9+Vspzuxxx9JRAXhnyccRj3GyCMdQ=="],
|
||||
|
||||
"@types/node": ["@types/node@22.15.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-EV/37Td6c+MgKAbkcLG6vqZ2zEYHD7bvSrzqqs2RIhbA6w3x+Dqz8MZM3sP6kGTeLrdoOgKZe+Xja7tUB2DNkQ=="],
|
||||
"@types/node": ["@types/node@24.0.13", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-Qm9OYVOFHFYg3wJoTSrz80hoec5Lia/dPp84do3X7dZvLikQvM1YpmvTBEdIr/e+U8HTkFjLHLnl78K/qjf+jQ=="],
|
||||
|
||||
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
||||
|
||||
"before-after-hook": ["before-after-hook@2.2.3", "", {}, "sha512-NzUnlZexiaH/46WDhANlyR2bXRopNg4F/zuSA3OpZnllCUgRaOF2znDioDWrmbNVsuZk6l9pMquQB38cfBZwkQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.2.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-rRjA1T6n7wto4gxhAO/ErZEtOXyEZEmnIHQfl0Dt1QQSB4QV0iP6BZ9/YB5fZaHFQ2dwHFrmPaRQ9GGMX01k9Q=="],
|
||||
"bun-types": ["bun-types@1.2.18", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-04+Eha5NP7Z0A9YgDAzMk5PHR16ZuLVa83b26kH5+cp1qZW4F6FmAURngE7INf4tKOvCE69vYvDEwoNl1tGiWw=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
|
||||
"deprecation": ["deprecation@2.3.1", "", {}, "sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ=="],
|
||||
|
||||
@@ -68,7 +72,7 @@
|
||||
|
||||
"undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="],
|
||||
|
||||
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
|
||||
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
||||
|
||||
"universal-user-agent": ["universal-user-agent@6.0.1", "", {}, "sha512-yCzhz6FN2wU1NiiQRogkTQszlQSlpWaw8SvVegAc+bDxbzHgh1vX8uIe8OYyMH6DwH+sdTJsgMl36+mSMdRJIQ=="],
|
||||
|
||||
|
||||
4
.github/actions/codex/package.json
vendored
4
.github/actions/codex/package.json
vendored
@@ -13,8 +13,8 @@
|
||||
"@actions/github": "^6.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "^1.2.11",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/bun": "^1.2.18",
|
||||
"@types/node": "^24.0.13",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "^5.8.3"
|
||||
}
|
||||
|
||||
47
codex-rs/Cargo.lock
generated
47
codex-rs/Cargo.lock
generated
@@ -574,6 +574,22 @@ dependencies = [
|
||||
"tree-sitter-bash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-chatgpt"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-login",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "codex-cli"
|
||||
version = "0.0.0"
|
||||
@@ -581,6 +597,7 @@ dependencies = [
|
||||
"anyhow",
|
||||
"clap",
|
||||
"clap_complete",
|
||||
"codex-chatgpt",
|
||||
"codex-common",
|
||||
"codex-core",
|
||||
"codex-exec",
|
||||
@@ -770,6 +787,7 @@ dependencies = [
|
||||
"color-eyre",
|
||||
"crossterm",
|
||||
"image",
|
||||
"insta",
|
||||
"lazy_static",
|
||||
"mcp-types",
|
||||
"path-clean",
|
||||
@@ -854,6 +872,18 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console"
|
||||
version = "0.15.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
|
||||
dependencies = [
|
||||
"encode_unicode",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.6.0"
|
||||
@@ -1213,6 +1243,12 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encode_unicode"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.35"
|
||||
@@ -2093,6 +2129,17 @@ version = "2.0.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f4c7245a08504955605670dbf141fceab975f15ca21570696aebe9d2e71576bd"
|
||||
|
||||
[[package]]
|
||||
name = "insta"
|
||||
version = "1.43.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "154934ea70c58054b556dd430b99a98c2a7ff5309ac9891597e339b5c28f4371"
|
||||
dependencies = [
|
||||
"console",
|
||||
"once_cell",
|
||||
"similar",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "instability"
|
||||
version = "0.3.7"
|
||||
|
||||
21
codex-rs/chatgpt/Cargo.toml
Normal file
21
codex-rs/chatgpt/Cargo.toml
Normal file
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "codex-chatgpt"
|
||||
version = { workspace = true }
|
||||
edition = "2024"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-core = { path = "../core" }
|
||||
codex-login = { path = "../login" }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
5
codex-rs/chatgpt/README.md
Normal file
5
codex-rs/chatgpt/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# ChatGPT
|
||||
|
||||
This crate pertains to first party ChatGPT APIs and products such as Codex agent.
|
||||
|
||||
This crate should be primarily built and maintained by OpenAI employees. Please reach out to a maintainer before making an external contribution.
|
||||
89
codex-rs/chatgpt/src/apply_command.rs
Normal file
89
codex-rs/chatgpt/src/apply_command.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
use clap::Parser;
|
||||
use codex_common::CliConfigOverrides;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::ConfigOverrides;
|
||||
|
||||
use crate::chatgpt_token::init_chatgpt_token_from_auth;
|
||||
use crate::get_task::GetTaskResponse;
|
||||
use crate::get_task::OutputItem;
|
||||
use crate::get_task::PrOutputItem;
|
||||
use crate::get_task::get_task;
|
||||
|
||||
/// Applies the latest diff from a Codex agent task.
|
||||
#[derive(Debug, Parser)]
|
||||
pub struct ApplyCommand {
|
||||
pub task_id: String,
|
||||
|
||||
#[clap(flatten)]
|
||||
pub config_overrides: CliConfigOverrides,
|
||||
}
|
||||
pub async fn run_apply_command(apply_cli: ApplyCommand) -> anyhow::Result<()> {
|
||||
let config = Config::load_with_cli_overrides(
|
||||
apply_cli
|
||||
.config_overrides
|
||||
.parse_overrides()
|
||||
.map_err(anyhow::Error::msg)?,
|
||||
ConfigOverrides::default(),
|
||||
)?;
|
||||
|
||||
init_chatgpt_token_from_auth(&config.codex_home).await?;
|
||||
|
||||
let task_response = get_task(&config, apply_cli.task_id).await?;
|
||||
apply_diff_from_task(task_response).await
|
||||
}
|
||||
|
||||
pub async fn apply_diff_from_task(task_response: GetTaskResponse) -> anyhow::Result<()> {
|
||||
let diff_turn = match task_response.current_diff_task_turn {
|
||||
Some(turn) => turn,
|
||||
None => anyhow::bail!("No diff turn found"),
|
||||
};
|
||||
let output_diff = diff_turn.output_items.iter().find_map(|item| match item {
|
||||
OutputItem::Pr(PrOutputItem { output_diff }) => Some(output_diff),
|
||||
_ => None,
|
||||
});
|
||||
match output_diff {
|
||||
Some(output_diff) => apply_diff(&output_diff.diff).await,
|
||||
None => anyhow::bail!("No PR output item found"),
|
||||
}
|
||||
}
|
||||
|
||||
async fn apply_diff(diff: &str) -> anyhow::Result<()> {
|
||||
let toplevel_output = tokio::process::Command::new("git")
|
||||
.args(vec!["rev-parse", "--show-toplevel"])
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !toplevel_output.status.success() {
|
||||
anyhow::bail!("apply must be run from a git repository.");
|
||||
}
|
||||
|
||||
let repo_root = String::from_utf8(toplevel_output.stdout)?
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
let mut git_apply_cmd = tokio::process::Command::new("git")
|
||||
.args(vec!["apply", "--3way"])
|
||||
.current_dir(&repo_root)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.spawn()?;
|
||||
|
||||
if let Some(mut stdin) = git_apply_cmd.stdin.take() {
|
||||
tokio::io::AsyncWriteExt::write_all(&mut stdin, diff.as_bytes()).await?;
|
||||
drop(stdin);
|
||||
}
|
||||
|
||||
let output = git_apply_cmd.wait_with_output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"Git apply failed with status {}: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
println!("Successfully applied diff");
|
||||
Ok(())
|
||||
}
|
||||
45
codex-rs/chatgpt/src/chatgpt_client.rs
Normal file
45
codex-rs/chatgpt/src/chatgpt_client.rs
Normal file
@@ -0,0 +1,45 @@
|
||||
use codex_core::config::Config;
|
||||
|
||||
use crate::chatgpt_token::get_chatgpt_token_data;
|
||||
use crate::chatgpt_token::init_chatgpt_token_from_auth;
|
||||
|
||||
use anyhow::Context;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Make a GET request to the ChatGPT backend API.
|
||||
pub(crate) async fn chatgpt_get_request<T: DeserializeOwned>(
|
||||
config: &Config,
|
||||
path: String,
|
||||
) -> anyhow::Result<T> {
|
||||
let chatgpt_base_url = &config.chatgpt_base_url;
|
||||
init_chatgpt_token_from_auth(&config.codex_home).await?;
|
||||
|
||||
// Make direct HTTP request to ChatGPT backend API with the token
|
||||
let client = reqwest::Client::new();
|
||||
let url = format!("{chatgpt_base_url}{path}");
|
||||
|
||||
let token =
|
||||
get_chatgpt_token_data().ok_or_else(|| anyhow::anyhow!("ChatGPT token not available"))?;
|
||||
|
||||
let response = client
|
||||
.get(&url)
|
||||
.bearer_auth(&token.access_token)
|
||||
.header("chatgpt-account-id", &token.account_id)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("User-Agent", "codex-cli")
|
||||
.send()
|
||||
.await
|
||||
.context("Failed to send request")?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let result: T = response
|
||||
.json()
|
||||
.await
|
||||
.context("Failed to parse JSON response")?;
|
||||
Ok(result)
|
||||
} else {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("Request failed with status {}: {}", status, body)
|
||||
}
|
||||
}
|
||||
24
codex-rs/chatgpt/src/chatgpt_token.rs
Normal file
24
codex-rs/chatgpt/src/chatgpt_token.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_login::TokenData;
|
||||
|
||||
static CHATGPT_TOKEN: LazyLock<RwLock<Option<TokenData>>> = LazyLock::new(|| RwLock::new(None));
|
||||
|
||||
pub fn get_chatgpt_token_data() -> Option<TokenData> {
|
||||
CHATGPT_TOKEN.read().ok()?.clone()
|
||||
}
|
||||
|
||||
pub fn set_chatgpt_token_data(value: TokenData) {
|
||||
if let Ok(mut guard) = CHATGPT_TOKEN.write() {
|
||||
*guard = Some(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Initialize the ChatGPT token from auth.json file
|
||||
pub async fn init_chatgpt_token_from_auth(codex_home: &Path) -> std::io::Result<()> {
|
||||
let auth_json = codex_login::try_read_auth_json(codex_home).await?;
|
||||
set_chatgpt_token_data(auth_json.tokens.clone());
|
||||
Ok(())
|
||||
}
|
||||
40
codex-rs/chatgpt/src/get_task.rs
Normal file
40
codex-rs/chatgpt/src/get_task.rs
Normal file
@@ -0,0 +1,40 @@
|
||||
use codex_core::config::Config;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::chatgpt_client::chatgpt_get_request;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct GetTaskResponse {
|
||||
pub current_diff_task_turn: Option<AssistantTurn>,
|
||||
}
|
||||
|
||||
// Only relevant fields for our extraction
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct AssistantTurn {
|
||||
pub output_items: Vec<OutputItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum OutputItem {
|
||||
#[serde(rename = "pr")]
|
||||
Pr(PrOutputItem),
|
||||
|
||||
#[serde(other)]
|
||||
Other,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct PrOutputItem {
|
||||
pub output_diff: OutputDiff,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct OutputDiff {
|
||||
pub diff: String,
|
||||
}
|
||||
|
||||
pub(crate) async fn get_task(config: &Config, task_id: String) -> anyhow::Result<GetTaskResponse> {
|
||||
let path = format!("/wham/tasks/{task_id}");
|
||||
chatgpt_get_request(config, path).await
|
||||
}
|
||||
4
codex-rs/chatgpt/src/lib.rs
Normal file
4
codex-rs/chatgpt/src/lib.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod apply_command;
|
||||
mod chatgpt_client;
|
||||
mod chatgpt_token;
|
||||
pub mod get_task;
|
||||
191
codex-rs/chatgpt/tests/apply_command_e2e.rs
Normal file
191
codex-rs/chatgpt/tests/apply_command_e2e.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
#![expect(clippy::expect_used)]
|
||||
|
||||
use codex_chatgpt::apply_command::apply_diff_from_task;
|
||||
use codex_chatgpt::get_task::GetTaskResponse;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Creates a temporary git repository with initial commit
|
||||
async fn create_temp_git_repo() -> anyhow::Result<TempDir> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let repo_path = temp_dir.path();
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["init"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"Failed to initialize git repo: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Command::new("git")
|
||||
.args(["config", "user.email", "test@example.com"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
Command::new("git")
|
||||
.args(["config", "user.name", "Test User"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
std::fs::write(repo_path.join("README.md"), "# Test Repo\n")?;
|
||||
|
||||
Command::new("git")
|
||||
.args(["add", "README.md"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
let output = Command::new("git")
|
||||
.args(["commit", "-m", "Initial commit"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"Failed to create initial commit: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
Ok(temp_dir)
|
||||
}
|
||||
|
||||
async fn mock_get_task_with_fixture() -> anyhow::Result<GetTaskResponse> {
|
||||
let fixture_path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/task_turn_fixture.json");
|
||||
let fixture_content = std::fs::read_to_string(fixture_path)?;
|
||||
let response: GetTaskResponse = serde_json::from_str(&fixture_content)?;
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_command_creates_fibonacci_file() {
|
||||
let temp_repo = create_temp_git_repo()
|
||||
.await
|
||||
.expect("Failed to create temp git repo");
|
||||
let repo_path = temp_repo.path();
|
||||
|
||||
let task_response = mock_get_task_with_fixture()
|
||||
.await
|
||||
.expect("Failed to load fixture");
|
||||
|
||||
let original_dir = std::env::current_dir().expect("Failed to get current dir");
|
||||
std::env::set_current_dir(repo_path).expect("Failed to change directory");
|
||||
struct DirGuard(std::path::PathBuf);
|
||||
impl Drop for DirGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::env::set_current_dir(&self.0);
|
||||
}
|
||||
}
|
||||
let _guard = DirGuard(original_dir);
|
||||
|
||||
apply_diff_from_task(task_response)
|
||||
.await
|
||||
.expect("Failed to apply diff from task");
|
||||
|
||||
// Assert that fibonacci.js was created in scripts/ directory
|
||||
let fibonacci_path = repo_path.join("scripts/fibonacci.js");
|
||||
assert!(fibonacci_path.exists(), "fibonacci.js was not created");
|
||||
|
||||
// Verify the file contents match expected
|
||||
let contents = std::fs::read_to_string(&fibonacci_path).expect("Failed to read fibonacci.js");
|
||||
assert!(
|
||||
contents.contains("function fibonacci(n)"),
|
||||
"fibonacci.js doesn't contain expected function"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("#!/usr/bin/env node"),
|
||||
"fibonacci.js doesn't have shebang"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("module.exports = fibonacci;"),
|
||||
"fibonacci.js doesn't export function"
|
||||
);
|
||||
|
||||
// Verify file has correct number of lines (31 as specified in fixture)
|
||||
let line_count = contents.lines().count();
|
||||
assert_eq!(
|
||||
line_count, 31,
|
||||
"fibonacci.js should have 31 lines, got {line_count}",
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_apply_command_with_merge_conflicts() {
|
||||
let temp_repo = create_temp_git_repo()
|
||||
.await
|
||||
.expect("Failed to create temp git repo");
|
||||
let repo_path = temp_repo.path();
|
||||
|
||||
// Create conflicting fibonacci.js file first
|
||||
let scripts_dir = repo_path.join("scripts");
|
||||
std::fs::create_dir_all(&scripts_dir).expect("Failed to create scripts directory");
|
||||
|
||||
let conflicting_content = r#"#!/usr/bin/env node
|
||||
|
||||
// This is a different fibonacci implementation
|
||||
function fib(num) {
|
||||
if (num <= 1) return num;
|
||||
return fib(num - 1) + fib(num - 2);
|
||||
}
|
||||
|
||||
console.log("Running fibonacci...");
|
||||
console.log(fib(10));
|
||||
"#;
|
||||
|
||||
let fibonacci_path = scripts_dir.join("fibonacci.js");
|
||||
std::fs::write(&fibonacci_path, conflicting_content).expect("Failed to write conflicting file");
|
||||
|
||||
Command::new("git")
|
||||
.args(["add", "scripts/fibonacci.js"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to add fibonacci.js");
|
||||
|
||||
Command::new("git")
|
||||
.args(["commit", "-m", "Add conflicting fibonacci implementation"])
|
||||
.current_dir(repo_path)
|
||||
.output()
|
||||
.await
|
||||
.expect("Failed to commit conflicting file");
|
||||
|
||||
let original_dir = std::env::current_dir().expect("Failed to get current dir");
|
||||
std::env::set_current_dir(repo_path).expect("Failed to change directory");
|
||||
struct DirGuard(std::path::PathBuf);
|
||||
impl Drop for DirGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::env::set_current_dir(&self.0);
|
||||
}
|
||||
}
|
||||
let _guard = DirGuard(original_dir);
|
||||
|
||||
let task_response = mock_get_task_with_fixture()
|
||||
.await
|
||||
.expect("Failed to load fixture");
|
||||
|
||||
let apply_result = apply_diff_from_task(task_response).await;
|
||||
|
||||
assert!(
|
||||
apply_result.is_err(),
|
||||
"Expected apply to fail due to merge conflicts"
|
||||
);
|
||||
|
||||
let contents = std::fs::read_to_string(&fibonacci_path).expect("Failed to read fibonacci.js");
|
||||
|
||||
assert!(
|
||||
contents.contains("<<<<<<< HEAD")
|
||||
|| contents.contains("=======")
|
||||
|| contents.contains(">>>>>>> "),
|
||||
"fibonacci.js should contain merge conflict markers, got: {contents}",
|
||||
);
|
||||
}
|
||||
65
codex-rs/chatgpt/tests/task_turn_fixture.json
Normal file
65
codex-rs/chatgpt/tests/task_turn_fixture.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"current_diff_task_turn": {
|
||||
"output_items": [
|
||||
{
|
||||
"type": "pr",
|
||||
"pr_title": "Add fibonacci script",
|
||||
"pr_message": "## Summary\n- add a basic Fibonacci script under `scripts/`\n\n## Testing\n- `node scripts/fibonacci.js 10`\n- `npm run lint` *(fails: next not found)*",
|
||||
"output_diff": {
|
||||
"type": "output_diff",
|
||||
"repo_id": "/workspace/rddit-vercel",
|
||||
"base_commit_sha": "1a2e9baf2ce2fdd0c126b47b1bcfd512de2a9f7b",
|
||||
"diff": "diff --git a/scripts/fibonacci.js b/scripts/fibonacci.js\nnew file mode 100644\nindex 0000000000000000000000000000000000000000..6c9fdfdbf8669b7968936411050525b995d0a9a6\n--- /dev/null\n+++ b/scripts/fibonacci.js\n@@ -0,0 +1,31 @@\n+#!/usr/bin/env node\n+\n+function fibonacci(n) {\n+ if (n < 0) {\n+ throw new Error(\"n must be non-negative\");\n+ }\n+ let a = 0;\n+ let b = 1;\n+ for (let i = 0; i < n; i++) {\n+ const next = a + b;\n+ a = b;\n+ b = next;\n+ }\n+ return a;\n+}\n+\n+function printUsage() {\n+ console.log(\"Usage: node scripts/fibonacci.js <n>\");\n+}\n+\n+if (require.main === module) {\n+ const arg = process.argv[2];\n+ if (arg === undefined || isNaN(Number(arg))) {\n+ printUsage();\n+ process.exit(1);\n+ }\n+ const n = Number(arg);\n+ console.log(fibonacci(n));\n+}\n+\n+module.exports = fibonacci;\n",
|
||||
"external_storage_diff": {
|
||||
"file_id": "file_00000000114c61f786900f8c2130ace7",
|
||||
"ttl": null
|
||||
},
|
||||
"files_modified": 1,
|
||||
"lines_added": 31,
|
||||
"lines_removed": 0,
|
||||
"commit_message": "Add fibonacci script"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{
|
||||
"content_type": "text",
|
||||
"text": "**Summary**\n\n- Created a command-line Fibonacci script that validates input and prints the result when executed with Node"
|
||||
},
|
||||
{
|
||||
"content_type": "repo_file_citation",
|
||||
"path": "scripts/fibonacci.js",
|
||||
"line_range_start": 1,
|
||||
"line_range_end": 31
|
||||
},
|
||||
{
|
||||
"content_type": "text",
|
||||
"text": "\n\n**Testing**\n\n- ❌ `npm run lint` (failed to run `next lint`)"
|
||||
},
|
||||
{
|
||||
"content_type": "terminal_chunk_citation",
|
||||
"terminal_chunk_id": "7dd543",
|
||||
"line_range_start": 1,
|
||||
"line_range_end": 5
|
||||
},
|
||||
{
|
||||
"content_type": "text",
|
||||
"text": "\n- ✅ `node scripts/fibonacci.js 10` produced “55”"
|
||||
},
|
||||
{
|
||||
"content_type": "terminal_chunk_citation",
|
||||
"terminal_chunk_id": "6ee559",
|
||||
"line_range_start": 1,
|
||||
"line_range_end": 3
|
||||
},
|
||||
{
|
||||
"content_type": "text",
|
||||
"text": "\n\nCodex couldn't run certain commands due to environment limitations. Consider configuring a setup script or internet access in your Codex environment to install dependencies."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ workspace = true
|
||||
anyhow = "1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
clap_complete = "4"
|
||||
codex-chatgpt = { path = "../chatgpt" }
|
||||
codex-core = { path = "../core" }
|
||||
codex-common = { path = "../common", features = ["cli"] }
|
||||
codex-exec = { path = "../exec" }
|
||||
|
||||
@@ -2,6 +2,8 @@ use clap::CommandFactory;
|
||||
use clap::Parser;
|
||||
use clap_complete::Shell;
|
||||
use clap_complete::generate;
|
||||
use codex_chatgpt::apply_command::ApplyCommand;
|
||||
use codex_chatgpt::apply_command::run_apply_command;
|
||||
use codex_cli::LandlockCommand;
|
||||
use codex_cli::SeatbeltCommand;
|
||||
use codex_cli::login::run_login_with_chatgpt;
|
||||
@@ -55,6 +57,10 @@ enum Subcommand {
|
||||
|
||||
/// Internal debugging commands.
|
||||
Debug(DebugArgs),
|
||||
|
||||
/// Apply the latest diff produced by Codex agent as a `git apply` to your local working tree.
|
||||
#[clap(visible_alias = "a")]
|
||||
Apply(ApplyCommand),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -137,6 +143,10 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
.await?;
|
||||
}
|
||||
},
|
||||
Some(Subcommand::Apply(mut apply_cli)) => {
|
||||
prepend_config_flags(&mut apply_cli.config_overrides, cli.config_overrides);
|
||||
run_apply_command(apply_cli).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -134,6 +134,9 @@ pub struct Config {
|
||||
/// When set to `true`, overrides the default heuristic and forces
|
||||
/// `model_supports_reasoning_summaries()` to return `true`.
|
||||
pub model_supports_reasoning_summaries: bool,
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: String,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -315,6 +318,9 @@ pub struct ConfigToml {
|
||||
|
||||
/// Override to force-enable reasoning summaries for the configured model.
|
||||
pub model_supports_reasoning_summaries: Option<bool>,
|
||||
|
||||
/// Base URL for requests to ChatGPT (as opposed to the OpenAI API).
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl ConfigToml {
|
||||
@@ -483,6 +489,11 @@ impl Config {
|
||||
model_supports_reasoning_summaries: cfg
|
||||
.model_supports_reasoning_summaries
|
||||
.unwrap_or(false),
|
||||
|
||||
chatgpt_base_url: config_profile
|
||||
.chatgpt_base_url
|
||||
.or(cfg.chatgpt_base_url)
|
||||
.unwrap_or("https://chatgpt.com/backend-api/".to_string()),
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
@@ -788,6 +799,7 @@ disable_response_storage = true
|
||||
model_reasoning_effort: ReasoningEffort::High,
|
||||
model_reasoning_summary: ReasoningSummary::Detailed,
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
},
|
||||
o3_profile_config
|
||||
);
|
||||
@@ -833,6 +845,7 @@ disable_response_storage = true
|
||||
model_reasoning_effort: ReasoningEffort::default(),
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_gpt3_profile_config, gpt3_profile_config);
|
||||
@@ -893,6 +906,7 @@ disable_response_storage = true
|
||||
model_reasoning_effort: ReasoningEffort::default(),
|
||||
model_reasoning_summary: ReasoningSummary::default(),
|
||||
model_supports_reasoning_summaries: false,
|
||||
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(expected_zdr_profile_config, zdr_profile_config);
|
||||
|
||||
@@ -16,4 +16,5 @@ pub struct ConfigProfile {
|
||||
pub disable_response_storage: Option<bool>,
|
||||
pub model_reasoning_effort: Option<ReasoningEffort>,
|
||||
pub model_reasoning_summary: Option<ReasoningSummary>,
|
||||
pub chatgpt_base_url: Option<String>,
|
||||
}
|
||||
|
||||
@@ -59,6 +59,13 @@ pub async fn login_with_chatgpt(
|
||||
/// Attempt to read the `OPENAI_API_KEY` from the `auth.json` file in the given
|
||||
/// `CODEX_HOME` directory, refreshing it, if necessary.
|
||||
pub async fn try_read_openai_api_key(codex_home: &Path) -> std::io::Result<String> {
|
||||
let auth_dot_json = try_read_auth_json(codex_home).await?;
|
||||
Ok(auth_dot_json.openai_api_key)
|
||||
}
|
||||
|
||||
/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
|
||||
/// Returns the full AuthDotJson structure after refreshing if necessary.
|
||||
pub async fn try_read_auth_json(codex_home: &Path) -> std::io::Result<AuthDotJson> {
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
let mut file = std::fs::File::open(&auth_path)?;
|
||||
let mut contents = String::new();
|
||||
@@ -88,9 +95,9 @@ pub async fn try_read_openai_api_key(codex_home: &Path) -> std::io::Result<Strin
|
||||
file.flush()?;
|
||||
}
|
||||
|
||||
Ok(auth_dot_json.openai_api_key)
|
||||
Ok(auth_dot_json)
|
||||
} else {
|
||||
Ok(auth_dot_json.openai_api_key)
|
||||
Ok(auth_dot_json)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,23 +153,24 @@ struct RefreshResponse {
|
||||
|
||||
/// Expected structure for $CODEX_HOME/auth.json.
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct AuthDotJson {
|
||||
pub struct AuthDotJson {
|
||||
#[serde(rename = "OPENAI_API_KEY")]
|
||||
openai_api_key: String,
|
||||
pub openai_api_key: String,
|
||||
|
||||
tokens: TokenData,
|
||||
pub tokens: TokenData,
|
||||
|
||||
last_refresh: DateTime<Utc>,
|
||||
pub last_refresh: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct TokenData {
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct TokenData {
|
||||
/// This is a JWT.
|
||||
id_token: String,
|
||||
pub id_token: String,
|
||||
|
||||
/// This is a JWT.
|
||||
#[allow(dead_code)]
|
||||
access_token: String,
|
||||
pub access_token: String,
|
||||
|
||||
refresh_token: String,
|
||||
pub refresh_token: String,
|
||||
|
||||
pub account_id: String,
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ class TokenData:
|
||||
id_token: str
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
account_id: str
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -240,20 +241,26 @@ class _ApiKeyHTTPHandler(http.server.BaseHTTPRequestHandler):
|
||||
)
|
||||
) as resp:
|
||||
payload = json.loads(resp.read().decode())
|
||||
|
||||
# Extract chatgpt_account_id from id_token
|
||||
id_token_parts = payload["id_token"].split(".")
|
||||
if len(id_token_parts) != 3:
|
||||
raise ValueError("Invalid ID token")
|
||||
id_token_claims = _decode_jwt_segment(id_token_parts[1])
|
||||
auth_claims = id_token_claims.get("https://api.openai.com/auth", {})
|
||||
chatgpt_account_id = auth_claims.get("chatgpt_account_id", "")
|
||||
|
||||
token_data = TokenData(
|
||||
id_token=payload["id_token"],
|
||||
access_token=payload["access_token"],
|
||||
refresh_token=payload["refresh_token"],
|
||||
account_id=chatgpt_account_id,
|
||||
)
|
||||
|
||||
id_token_parts = token_data.id_token.split(".")
|
||||
if len(id_token_parts) != 3:
|
||||
raise ValueError("Invalid ID token")
|
||||
access_token_parts = token_data.access_token.split(".")
|
||||
if len(access_token_parts) != 3:
|
||||
raise ValueError("Invalid access token")
|
||||
|
||||
id_token_claims = _decode_jwt_segment(id_token_parts[1])
|
||||
access_token_claims = _decode_jwt_segment(access_token_parts[1])
|
||||
|
||||
token_claims = id_token_claims.get("https://api.openai.com/auth", {})
|
||||
@@ -375,6 +382,7 @@ def _write_auth_file(*, auth: AuthBundle, codex_home: str) -> bool:
|
||||
"id_token": auth.token_data.id_token,
|
||||
"access_token": auth.token_data.access_token,
|
||||
"refresh_token": auth.token_data.refresh_token,
|
||||
"account_id": auth.token_data.account_id,
|
||||
},
|
||||
"last_refresh": auth.last_refresh,
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_core::protocol::Event;
|
||||
use codex_core::protocol::EventMsg;
|
||||
use codex_core::protocol::InputItem;
|
||||
use codex_core::protocol::Op;
|
||||
use codex_core::protocol::Submission;
|
||||
use codex_core::protocol::TaskCompleteEvent;
|
||||
use mcp_types::CallToolResult;
|
||||
use mcp_types::CallToolResultContent;
|
||||
@@ -66,14 +67,24 @@ pub async fn run_codex_tool_session(
|
||||
.send(codex_event_to_notification(&first_event))
|
||||
.await;
|
||||
|
||||
if let Err(e) = codex
|
||||
.submit(Op::UserInput {
|
||||
// Use the original MCP request ID as the `sub_id` for the Codex submission so that
|
||||
// any events emitted for this tool-call can be correlated with the
|
||||
// originating `tools/call` request.
|
||||
let sub_id = match &id {
|
||||
RequestId::String(s) => s.clone(),
|
||||
RequestId::Integer(n) => n.to_string(),
|
||||
};
|
||||
|
||||
let submission = Submission {
|
||||
id: sub_id,
|
||||
op: Op::UserInput {
|
||||
items: vec![InputItem::Text {
|
||||
text: initial_prompt.clone(),
|
||||
}],
|
||||
})
|
||||
.await
|
||||
{
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = codex.submit_with_id(submission).await {
|
||||
tracing::error!("Failed to submit initial prompt: {e}");
|
||||
}
|
||||
|
||||
|
||||
@@ -61,4 +61,5 @@ unicode-segmentation = "1.12.0"
|
||||
uuid = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
insta = "1.43.1"
|
||||
pretty_assertions = "1"
|
||||
|
||||
@@ -98,21 +98,7 @@ impl<'a> App<'a> {
|
||||
scroll_event_helper.scroll_down();
|
||||
}
|
||||
crossterm::event::Event::Paste(pasted) => {
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
for ch in pasted.chars() {
|
||||
let key_event = match ch {
|
||||
'\n' | '\r' => {
|
||||
// Represent newline as <Shift+Enter> so that the bottom
|
||||
// pane treats it as a literal newline instead of a submit
|
||||
// action (submission is only triggered on Enter *without*
|
||||
// any modifiers).
|
||||
KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)
|
||||
}
|
||||
_ => KeyEvent::new(KeyCode::Char(ch), KeyModifiers::empty()),
|
||||
};
|
||||
app_event_tx.send(AppEvent::KeyEvent(key_event));
|
||||
}
|
||||
app_event_tx.send(AppEvent::Paste(pasted));
|
||||
}
|
||||
_ => {
|
||||
// Ignore any other events.
|
||||
@@ -223,6 +209,9 @@ impl<'a> App<'a> {
|
||||
AppEvent::Scroll(scroll_delta) => {
|
||||
self.dispatch_scroll_event(scroll_delta);
|
||||
}
|
||||
AppEvent::Paste(text) => {
|
||||
self.dispatch_paste_event(text);
|
||||
}
|
||||
AppEvent::CodexEvent(event) => {
|
||||
self.dispatch_codex_event(event);
|
||||
}
|
||||
@@ -343,6 +332,13 @@ impl<'a> App<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_paste_event(&mut self, pasted: String) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_paste(pasted),
|
||||
AppState::Login { .. } | AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_scroll_event(&mut self, scroll_delta: i32) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_scroll_delta(scroll_delta),
|
||||
|
||||
@@ -12,6 +12,9 @@ pub(crate) enum AppEvent {
|
||||
|
||||
KeyEvent(KeyEvent),
|
||||
|
||||
/// Text pasted from the terminal clipboard.
|
||||
Paste(String),
|
||||
|
||||
/// Scroll event with a value representing the "scroll delta" as the net
|
||||
/// scroll up/down events within a short time window.
|
||||
Scroll(i32),
|
||||
|
||||
@@ -28,6 +28,9 @@ const MIN_TEXTAREA_ROWS: usize = 1;
|
||||
const BORDER_LINES: u16 = 2;
|
||||
|
||||
const BASE_PLACEHOLDER_TEXT: &str = "send a message";
|
||||
/// If the pasted content exceeds this number of characters, replace it with a
|
||||
/// placeholder in the UI.
|
||||
const LARGE_PASTE_CHAR_THRESHOLD: usize = 1000;
|
||||
|
||||
/// Result returned when the user interacts with the text area.
|
||||
pub enum InputResult {
|
||||
@@ -43,6 +46,7 @@ pub(crate) struct ChatComposer<'a> {
|
||||
ctrl_c_quit_hint: bool,
|
||||
dismissed_file_popup_token: Option<String>,
|
||||
current_file_query: Option<String>,
|
||||
pending_pastes: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Popup state – at most one can be visible at any time.
|
||||
@@ -66,6 +70,7 @@ impl ChatComposer<'_> {
|
||||
ctrl_c_quit_hint: false,
|
||||
dismissed_file_popup_token: None,
|
||||
current_file_query: None,
|
||||
pending_pastes: Vec::new(),
|
||||
};
|
||||
this.update_border(has_input_focus);
|
||||
this
|
||||
@@ -126,6 +131,20 @@ impl ChatComposer<'_> {
|
||||
self.update_border(has_focus);
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, pasted: String) -> bool {
|
||||
let char_count = pasted.chars().count();
|
||||
if char_count > LARGE_PASTE_CHAR_THRESHOLD {
|
||||
let placeholder = format!("[Pasted Content {char_count} chars]");
|
||||
self.textarea.insert_str(&placeholder);
|
||||
self.pending_pastes.push((placeholder, pasted));
|
||||
} else {
|
||||
self.textarea.insert_str(&pasted);
|
||||
}
|
||||
self.sync_command_popup();
|
||||
self.sync_file_search_popup();
|
||||
true
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Only apply if user is still editing a token starting with `query`.
|
||||
@@ -414,10 +433,18 @@ impl ChatComposer<'_> {
|
||||
alt: false,
|
||||
ctrl: false,
|
||||
} => {
|
||||
let text = self.textarea.lines().join("\n");
|
||||
let mut text = self.textarea.lines().join("\n");
|
||||
self.textarea.select_all();
|
||||
self.textarea.cut();
|
||||
|
||||
// Replace all pending pastes in the text
|
||||
for (placeholder, actual) in &self.pending_pastes {
|
||||
if text.contains(placeholder) {
|
||||
text = text.replace(placeholder, actual);
|
||||
}
|
||||
}
|
||||
self.pending_pastes.clear();
|
||||
|
||||
if text.is_empty() {
|
||||
(InputResult::None, true)
|
||||
} else {
|
||||
@@ -443,10 +470,71 @@ impl ChatComposer<'_> {
|
||||
|
||||
/// Handle generic Input events that modify the textarea content.
|
||||
fn handle_input_basic(&mut self, input: Input) -> (InputResult, bool) {
|
||||
// Special handling for backspace on placeholders
|
||||
if let Input {
|
||||
key: Key::Backspace,
|
||||
..
|
||||
} = input
|
||||
{
|
||||
if self.try_remove_placeholder_at_cursor() {
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Normal input handling
|
||||
self.textarea.input(input);
|
||||
let text_after = self.textarea.lines().join("\n");
|
||||
|
||||
// Check if any placeholders were removed and remove their corresponding pending pastes
|
||||
self.pending_pastes
|
||||
.retain(|(placeholder, _)| text_after.contains(placeholder));
|
||||
|
||||
(InputResult::None, true)
|
||||
}
|
||||
|
||||
/// Attempts to remove a placeholder if the cursor is at the end of one.
|
||||
/// Returns true if a placeholder was removed.
|
||||
fn try_remove_placeholder_at_cursor(&mut self) -> bool {
|
||||
let (row, col) = self.textarea.cursor();
|
||||
let line = self
|
||||
.textarea
|
||||
.lines()
|
||||
.get(row)
|
||||
.map(|s| s.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Find any placeholder that ends at the cursor position
|
||||
let placeholder_to_remove = self.pending_pastes.iter().find_map(|(ph, _)| {
|
||||
if col < ph.len() {
|
||||
return None;
|
||||
}
|
||||
let potential_ph_start = col - ph.len();
|
||||
if line[potential_ph_start..col] == *ph {
|
||||
Some(ph.clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(placeholder) = placeholder_to_remove {
|
||||
// Remove the entire placeholder from the text
|
||||
let placeholder_len = placeholder.len();
|
||||
for _ in 0..placeholder_len {
|
||||
self.textarea.input(Input {
|
||||
key: Key::Backspace,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
shift: false,
|
||||
});
|
||||
}
|
||||
// Remove from pending pastes
|
||||
self.pending_pastes.retain(|(ph, _)| ph != &placeholder);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Synchronize `self.command_popup` with the current text in the
|
||||
/// textarea. This must be called after every modification that can change
|
||||
/// the text so the popup is shown/updated/hidden as appropriate.
|
||||
@@ -624,7 +712,10 @@ impl WidgetRef for &ChatComposer<'_> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::bottom_pane::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::bottom_pane::chat_composer::LARGE_PASTE_CHAR_THRESHOLD;
|
||||
use tui_textarea::TextArea;
|
||||
|
||||
#[test]
|
||||
@@ -770,4 +861,324 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_paste_small_inserts_text() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, sender);
|
||||
|
||||
let needs_redraw = composer.handle_paste("hello".to_string());
|
||||
assert!(needs_redraw);
|
||||
assert_eq!(composer.textarea.lines(), ["hello"]);
|
||||
assert!(composer.pending_pastes.is_empty());
|
||||
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, "hello"),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handle_paste_large_uses_placeholder_and_replaces_on_submit() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, sender);
|
||||
|
||||
let large = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 10);
|
||||
let needs_redraw = composer.handle_paste(large.clone());
|
||||
assert!(needs_redraw);
|
||||
let placeholder = format!("[Pasted Content {} chars]", large.chars().count());
|
||||
assert_eq!(composer.textarea.lines(), [placeholder.as_str()]);
|
||||
assert_eq!(composer.pending_pastes.len(), 1);
|
||||
assert_eq!(composer.pending_pastes[0].0, placeholder);
|
||||
assert_eq!(composer.pending_pastes[0].1, large);
|
||||
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
match result {
|
||||
InputResult::Submitted(text) => assert_eq!(text, large),
|
||||
_ => panic!("expected Submitted"),
|
||||
}
|
||||
assert!(composer.pending_pastes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_clears_pending_paste() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let large = "y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 1);
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, sender);
|
||||
|
||||
composer.handle_paste(large);
|
||||
assert_eq!(composer.pending_pastes.len(), 1);
|
||||
|
||||
// Any edit that removes the placeholder should clear pending_paste
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
assert!(composer.pending_pastes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_snapshots() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
use insta::assert_snapshot;
|
||||
use ratatui::Terminal;
|
||||
use ratatui::backend::TestBackend;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut terminal = match Terminal::new(TestBackend::new(100, 10)) {
|
||||
Ok(t) => t,
|
||||
Err(e) => panic!("Failed to create terminal: {e}"),
|
||||
};
|
||||
|
||||
let test_cases = vec![
|
||||
("empty", None),
|
||||
("small", Some("short".to_string())),
|
||||
("large", Some("z".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5))),
|
||||
("multiple_pastes", None),
|
||||
("backspace_after_pastes", None),
|
||||
];
|
||||
|
||||
for (name, input) in test_cases {
|
||||
// Create a fresh composer for each test case
|
||||
let mut composer = ChatComposer::new(true, sender.clone());
|
||||
|
||||
if let Some(text) = input {
|
||||
composer.handle_paste(text);
|
||||
} else if name == "multiple_pastes" {
|
||||
// First large paste
|
||||
composer.handle_paste("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3));
|
||||
// Second large paste
|
||||
composer.handle_paste("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7));
|
||||
// Small paste
|
||||
composer.handle_paste(" another short paste".to_string());
|
||||
} else if name == "backspace_after_pastes" {
|
||||
// Three large pastes
|
||||
composer.handle_paste("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 2));
|
||||
composer.handle_paste("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4));
|
||||
composer.handle_paste("c".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6));
|
||||
// Move cursor to end and press backspace
|
||||
composer.textarea.move_cursor(tui_textarea::CursorMove::End);
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
terminal
|
||||
.draw(|f| f.render_widget_ref(&composer, f.area()))
|
||||
.unwrap_or_else(|e| panic!("Failed to draw {name} composer: {e}"));
|
||||
|
||||
assert_snapshot!(name, terminal.backend());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_pastes_submission() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, sender);
|
||||
|
||||
// Define test cases: (paste content, is_large)
|
||||
let test_cases = [
|
||||
("x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 3), true),
|
||||
(" and ".to_string(), false),
|
||||
("y".repeat(LARGE_PASTE_CHAR_THRESHOLD + 7), true),
|
||||
];
|
||||
|
||||
// Expected states after each paste
|
||||
let mut expected_text = String::new();
|
||||
let mut expected_pending_count = 0;
|
||||
|
||||
// Apply all pastes and build expected state
|
||||
let states: Vec<_> = test_cases
|
||||
.iter()
|
||||
.map(|(content, is_large)| {
|
||||
composer.handle_paste(content.clone());
|
||||
if *is_large {
|
||||
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
||||
expected_text.push_str(&placeholder);
|
||||
expected_pending_count += 1;
|
||||
} else {
|
||||
expected_text.push_str(content);
|
||||
}
|
||||
(expected_text.clone(), expected_pending_count)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Verify all intermediate states were correct
|
||||
assert_eq!(
|
||||
states,
|
||||
vec![
|
||||
(
|
||||
format!("[Pasted Content {} chars]", test_cases[0].0.chars().count()),
|
||||
1
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"[Pasted Content {} chars] and ",
|
||||
test_cases[0].0.chars().count()
|
||||
),
|
||||
1
|
||||
),
|
||||
(
|
||||
format!(
|
||||
"[Pasted Content {} chars] and [Pasted Content {} chars]",
|
||||
test_cases[0].0.chars().count(),
|
||||
test_cases[2].0.chars().count()
|
||||
),
|
||||
2
|
||||
),
|
||||
]
|
||||
);
|
||||
|
||||
// Submit and verify final expansion
|
||||
let (result, _) =
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
if let InputResult::Submitted(text) = result {
|
||||
assert_eq!(text, format!("{} and {}", test_cases[0].0, test_cases[2].0));
|
||||
} else {
|
||||
panic!("expected Submitted");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_placeholder_deletion() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, sender);
|
||||
|
||||
// Define test cases: (content, is_large)
|
||||
let test_cases = [
|
||||
("a".repeat(LARGE_PASTE_CHAR_THRESHOLD + 5), true),
|
||||
(" and ".to_string(), false),
|
||||
("b".repeat(LARGE_PASTE_CHAR_THRESHOLD + 6), true),
|
||||
];
|
||||
|
||||
// Apply all pastes
|
||||
let mut current_pos = 0;
|
||||
let states: Vec<_> = test_cases
|
||||
.iter()
|
||||
.map(|(content, is_large)| {
|
||||
composer.handle_paste(content.clone());
|
||||
if *is_large {
|
||||
let placeholder = format!("[Pasted Content {} chars]", content.chars().count());
|
||||
current_pos += placeholder.len();
|
||||
} else {
|
||||
current_pos += content.len();
|
||||
}
|
||||
(
|
||||
composer.textarea.lines().join("\n"),
|
||||
composer.pending_pastes.len(),
|
||||
current_pos,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Delete placeholders one by one and collect states
|
||||
let mut deletion_states = vec![];
|
||||
|
||||
// First deletion
|
||||
composer
|
||||
.textarea
|
||||
.move_cursor(tui_textarea::CursorMove::Jump(0, states[0].2 as u16));
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
deletion_states.push((
|
||||
composer.textarea.lines().join("\n"),
|
||||
composer.pending_pastes.len(),
|
||||
));
|
||||
|
||||
// Second deletion
|
||||
composer
|
||||
.textarea
|
||||
.move_cursor(tui_textarea::CursorMove::Jump(
|
||||
0,
|
||||
composer.textarea.lines().join("\n").len() as u16,
|
||||
));
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
deletion_states.push((
|
||||
composer.textarea.lines().join("\n"),
|
||||
composer.pending_pastes.len(),
|
||||
));
|
||||
|
||||
// Verify all states
|
||||
assert_eq!(
|
||||
deletion_states,
|
||||
vec![
|
||||
(" and [Pasted Content 1006 chars]".to_string(), 1),
|
||||
(" and ".to_string(), 0),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_partial_placeholder_deletion() {
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyModifiers;
|
||||
|
||||
let (tx, _rx) = std::sync::mpsc::channel();
|
||||
let sender = AppEventSender::new(tx);
|
||||
let mut composer = ChatComposer::new(true, sender);
|
||||
|
||||
// Define test cases: (cursor_position_from_end, expected_pending_count)
|
||||
let test_cases = [
|
||||
5, // Delete from middle - should clear tracking
|
||||
0, // Delete from end - should clear tracking
|
||||
];
|
||||
|
||||
let paste = "x".repeat(LARGE_PASTE_CHAR_THRESHOLD + 4);
|
||||
let placeholder = format!("[Pasted Content {} chars]", paste.chars().count());
|
||||
|
||||
let states: Vec<_> = test_cases
|
||||
.into_iter()
|
||||
.map(|pos_from_end| {
|
||||
composer.handle_paste(paste.clone());
|
||||
composer
|
||||
.textarea
|
||||
.move_cursor(tui_textarea::CursorMove::Jump(
|
||||
0,
|
||||
(placeholder.len() - pos_from_end) as u16,
|
||||
));
|
||||
composer.handle_key_event(KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE));
|
||||
let result = (
|
||||
composer.textarea.lines().join("\n").contains(&placeholder),
|
||||
composer.pending_pastes.len(),
|
||||
);
|
||||
composer.textarea.select_all();
|
||||
composer.textarea.cut();
|
||||
result
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
states,
|
||||
vec![
|
||||
(false, 0), // After deleting from middle
|
||||
(false, 0), // After deleting from end
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,15 @@ impl BottomPane<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_paste(&mut self, pasted: String) {
|
||||
if self.active_view.is_none() {
|
||||
let needs_redraw = self.composer.handle_paste(pasted);
|
||||
if needs_redraw {
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update the status indicator text (only when the `StatusIndicatorView` is
|
||||
/// active).
|
||||
pub(crate) fn update_status_text(&mut self, text: String) {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│[Pasted Content 1002 chars][Pasted Content 1004 chars] │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│ send a message │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│[Pasted Content 1005 chars] │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│[Pasted Content 1003 chars][Pasted Content 1007 chars] another short paste │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
@@ -0,0 +1,14 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/chat_composer.rs
|
||||
expression: terminal.backend()
|
||||
---
|
||||
"╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
|
||||
"│short │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"│ │"
|
||||
"╰───────────────────────────────────────────────Enter to send | Ctrl+D to quit | Ctrl+J for newline╯"
|
||||
@@ -174,6 +174,12 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_paste(&mut self, text: String) {
|
||||
if matches!(self.input_focus, InputFocus::BottomPane) {
|
||||
self.bottom_pane.handle_paste(text);
|
||||
}
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
let UserMessage { text, image_paths } = user_message;
|
||||
let mut items: Vec<InputItem> = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user