mirror of
https://github.com/openai/codex.git
synced 2026-04-24 14:45:27 +00:00
Compare commits
3 Commits
nornagon/t
...
pakrym/dis
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
141895b03b | ||
|
|
b39713424d | ||
|
|
89fda7a776 |
1
codex-rs/Cargo.lock
generated
1
codex-rs/Cargo.lock
generated
@@ -924,7 +924,6 @@ dependencies = [
|
||||
"strum 0.27.2",
|
||||
"strum_macros 0.27.2",
|
||||
"supports-color",
|
||||
"tempfile",
|
||||
"textwrap 0.16.2",
|
||||
"tokio",
|
||||
"tracing",
|
||||
|
||||
@@ -4,24 +4,42 @@ use codex_core::config::ConfigOverrides;
|
||||
use codex_login::AuthMode;
|
||||
use codex_login::CLIENT_ID;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::LoginServerInfo;
|
||||
use codex_login::OPENAI_API_KEY_ENV_VAR;
|
||||
use codex_login::ServerOptions;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_login::logout;
|
||||
use codex_login::run_login_server;
|
||||
use codex_login::run_server_blocking_with_notify;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
|
||||
pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
|
||||
let opts = ServerOptions::new(codex_home, CLIENT_ID.to_string());
|
||||
let server = run_login_server(opts, None)?;
|
||||
pub async fn login_with_chatgpt(codex_home: &Path) -> std::io::Result<()> {
|
||||
let (tx, rx) = mpsc::channel::<LoginServerInfo>();
|
||||
let client_id = CLIENT_ID;
|
||||
let codex_home = codex_home.to_path_buf();
|
||||
tokio::spawn(async move {
|
||||
match rx.recv() {
|
||||
Ok(LoginServerInfo {
|
||||
auth_url,
|
||||
actual_port,
|
||||
}) => {
|
||||
eprintln!(
|
||||
"Starting local login server on http://localhost:{actual_port}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}",
|
||||
);
|
||||
}
|
||||
_ => {
|
||||
tracing::error!("Failed to receive login server info");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
eprintln!(
|
||||
"Starting local login server on http://localhost:{}.\nIf your browser did not open, navigate to this URL to authenticate:\n\n{}",
|
||||
server.actual_port, server.auth_url,
|
||||
);
|
||||
|
||||
server.block_until_done()?;
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let opts = ServerOptions::new(&codex_home, client_id);
|
||||
run_server_blocking_with_notify(opts, Some(tx), None)
|
||||
})
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
|
||||
eprintln!("Successfully logged in");
|
||||
Ok(())
|
||||
@@ -30,7 +48,7 @@ pub async fn login_with_chatgpt(codex_home: PathBuf) -> std::io::Result<()> {
|
||||
pub async fn run_login_with_chatgpt(cli_config_overrides: CliConfigOverrides) -> ! {
|
||||
let config = load_config_or_exit(cli_config_overrides);
|
||||
|
||||
match login_with_chatgpt(config.codex_home).await {
|
||||
match login_with_chatgpt(&config.codex_home).await {
|
||||
Ok(_) => {
|
||||
eprintln!("Successfully logged in");
|
||||
std::process::exit(0);
|
||||
|
||||
@@ -16,9 +16,10 @@ use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
|
||||
pub use crate::server::LoginServer;
|
||||
pub use crate::server::LoginServerInfo;
|
||||
pub use crate::server::ServerOptions;
|
||||
pub use crate::server::run_login_server;
|
||||
pub use crate::server::run_server_blocking;
|
||||
pub use crate::server::run_server_blocking_with_notify;
|
||||
pub use crate::token_data::TokenData;
|
||||
use crate::token_data::parse_id_token;
|
||||
|
||||
@@ -251,6 +252,65 @@ pub fn logout(codex_home: &Path) -> std::io::Result<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents a running login server. The server can be stopped by calling `cancel()` on SpawnedLogin.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpawnedLogin {
|
||||
url: Arc<Mutex<Option<String>>>,
|
||||
done: Arc<Mutex<Option<bool>>>,
|
||||
shutdown: Arc<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
impl SpawnedLogin {
|
||||
pub fn get_login_url(&self) -> Option<String> {
|
||||
self.url.lock().ok().and_then(|u| u.clone())
|
||||
}
|
||||
|
||||
pub fn get_auth_result(&self) -> Option<bool> {
|
||||
self.done.lock().ok().and_then(|d| *d)
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.shutdown
|
||||
.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
|
||||
let (tx, rx) = std::sync::mpsc::channel::<LoginServerInfo>();
|
||||
let shutdown = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let done = Arc::new(Mutex::new(None::<bool>));
|
||||
let url = Arc::new(Mutex::new(None::<String>));
|
||||
|
||||
let codex_home_buf = codex_home.to_path_buf();
|
||||
let client_id = CLIENT_ID.to_string();
|
||||
|
||||
let shutdown_clone = shutdown.clone();
|
||||
let done_clone = done.clone();
|
||||
std::thread::spawn(move || {
|
||||
let opts = ServerOptions::new(&codex_home_buf, &client_id);
|
||||
let res = run_server_blocking_with_notify(opts, Some(tx), Some(shutdown_clone));
|
||||
let success = res.is_ok();
|
||||
if let Ok(mut lock) = done_clone.lock() {
|
||||
*lock = Some(success);
|
||||
}
|
||||
});
|
||||
|
||||
let url_clone = url.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(u) = rx.recv() {
|
||||
if let Ok(mut lock) = url_clone.lock() {
|
||||
*lock = Some(u.auth_url);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(SpawnedLogin {
|
||||
url,
|
||||
done,
|
||||
shutdown,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<()> {
|
||||
let auth_dot_json = AuthDotJson {
|
||||
openai_api_key: Some(api_key.to_string()),
|
||||
|
||||
@@ -1,40 +1,39 @@
|
||||
use std::io::{self};
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::thread;
|
||||
|
||||
use crate::AuthDotJson;
|
||||
use crate::get_auth_file;
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::pkce::generate_pkce;
|
||||
use base64::Engine;
|
||||
use chrono::Utc;
|
||||
use rand::RngCore;
|
||||
use tiny_http::Response;
|
||||
use tiny_http::Server;
|
||||
|
||||
use crate::AuthDotJson;
|
||||
use crate::get_auth_file;
|
||||
use crate::pkce::PkceCodes;
|
||||
use crate::pkce::generate_pkce;
|
||||
|
||||
const DEFAULT_ISSUER: &str = "https://auth.openai.com";
|
||||
const DEFAULT_PORT: u16 = 1455;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerOptions {
|
||||
pub codex_home: PathBuf,
|
||||
pub client_id: String,
|
||||
pub issuer: String,
|
||||
pub struct ServerOptions<'a> {
|
||||
pub codex_home: &'a Path,
|
||||
pub client_id: &'a str,
|
||||
pub issuer: &'a str,
|
||||
pub port: u16,
|
||||
pub open_browser: bool,
|
||||
pub force_state: Option<String>,
|
||||
}
|
||||
|
||||
impl ServerOptions {
|
||||
pub fn new(codex_home: PathBuf, client_id: String) -> Self {
|
||||
impl<'a> ServerOptions<'a> {
|
||||
pub fn new(codex_home: &'a Path, client_id: &'a str) -> Self {
|
||||
Self {
|
||||
codex_home,
|
||||
client_id: client_id.to_string(),
|
||||
issuer: DEFAULT_ISSUER.to_string(),
|
||||
client_id,
|
||||
issuer: DEFAULT_ISSUER,
|
||||
port: DEFAULT_PORT,
|
||||
open_browser: true,
|
||||
force_state: None,
|
||||
@@ -42,31 +41,21 @@ impl ServerOptions {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct LoginServer {
|
||||
#[allow(dead_code)]
|
||||
pub fn run_server_blocking(opts: ServerOptions) -> io::Result<()> {
|
||||
run_server_blocking_with_notify(opts, None, None)
|
||||
}
|
||||
|
||||
pub struct LoginServerInfo {
|
||||
pub auth_url: String,
|
||||
pub actual_port: u16,
|
||||
pub server_handle: thread::JoinHandle<io::Result<()>>,
|
||||
pub shutdown_flag: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl LoginServer {
|
||||
pub fn block_until_done(self) -> io::Result<()> {
|
||||
#[expect(clippy::expect_used)]
|
||||
self.server_handle
|
||||
.join()
|
||||
.expect("can't join on the server thread")
|
||||
}
|
||||
|
||||
pub fn cancel(&self) {
|
||||
self.shutdown_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run_login_server(
|
||||
pub fn run_server_blocking_with_notify(
|
||||
opts: ServerOptions,
|
||||
notify_started: Option<std::sync::mpsc::Sender<LoginServerInfo>>,
|
||||
shutdown_flag: Option<Arc<AtomicBool>>,
|
||||
) -> io::Result<LoginServer> {
|
||||
) -> io::Result<()> {
|
||||
let pkce = generate_pkce();
|
||||
let state = opts.force_state.clone().unwrap_or_else(generate_state);
|
||||
|
||||
@@ -82,138 +71,135 @@ pub fn run_login_server(
|
||||
};
|
||||
|
||||
let redirect_uri = format!("http://localhost:{actual_port}/auth/callback");
|
||||
let auth_url = build_authorize_url(&opts.issuer, &opts.client_id, &redirect_uri, &pkce, &state);
|
||||
let auth_url = build_authorize_url(opts.issuer, opts.client_id, &redirect_uri, &pkce, &state);
|
||||
|
||||
if let Some(tx) = ¬ify_started {
|
||||
let _ = tx.send(LoginServerInfo {
|
||||
auth_url: auth_url.clone(),
|
||||
actual_port,
|
||||
});
|
||||
}
|
||||
|
||||
if opts.open_browser {
|
||||
let _ = webbrowser::open(&auth_url);
|
||||
}
|
||||
let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
|
||||
let shutdown_flag_clone = shutdown_flag.clone();
|
||||
let server_handle = thread::spawn(move || {
|
||||
while !shutdown_flag.load(Ordering::SeqCst) {
|
||||
let req = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(io::Error::other(e)),
|
||||
};
|
||||
|
||||
let url_raw = req.url().to_string();
|
||||
let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
eprintln!("URL parse error: {e}");
|
||||
let _ = req.respond(Response::from_string("Bad Request").with_status_code(400));
|
||||
let shutdown_flag = shutdown_flag.unwrap_or_else(|| Arc::new(AtomicBool::new(false)));
|
||||
while !shutdown_flag.load(Ordering::SeqCst) {
|
||||
let req = match server.recv() {
|
||||
Ok(r) => r,
|
||||
Err(e) => return Err(io::Error::other(e)),
|
||||
};
|
||||
|
||||
let url_raw = req.url().to_string();
|
||||
let parsed_url = match url::Url::parse(&format!("http://localhost{url_raw}")) {
|
||||
Ok(u) => u,
|
||||
Err(e) => {
|
||||
eprintln!("URL parse error: {e}");
|
||||
let _ = req.respond(Response::from_string("Bad Request").with_status_code(400));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = parsed_url.path().to_string();
|
||||
|
||||
match path.as_str() {
|
||||
"/auth/callback" => {
|
||||
let params: std::collections::HashMap<String, String> =
|
||||
parsed_url.query_pairs().into_owned().collect();
|
||||
if params.get("state").map(String::as_str) != Some(state.as_str()) {
|
||||
let _ =
|
||||
req.respond(Response::from_string("State mismatch").with_status_code(400));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = parsed_url.path().to_string();
|
||||
|
||||
match path.as_str() {
|
||||
"/auth/callback" => {
|
||||
let params: std::collections::HashMap<String, String> =
|
||||
parsed_url.query_pairs().into_owned().collect();
|
||||
if params.get("state").map(String::as_str) != Some(state.as_str()) {
|
||||
let _ = req
|
||||
.respond(Response::from_string("State mismatch").with_status_code(400));
|
||||
let code = match params.get("code") {
|
||||
Some(c) if !c.is_empty() => c.clone(),
|
||||
_ => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("Missing authorization code")
|
||||
.with_status_code(400),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let code = match params.get("code") {
|
||||
Some(c) if !c.is_empty() => c.clone(),
|
||||
_ => {
|
||||
};
|
||||
|
||||
match exchange_code_for_tokens(
|
||||
opts.issuer,
|
||||
opts.client_id,
|
||||
&redirect_uri,
|
||||
&pkce,
|
||||
&code,
|
||||
) {
|
||||
Ok(tokens) => {
|
||||
// Obtain API key via token-exchange and persist
|
||||
let api_key =
|
||||
obtain_api_key(opts.issuer, opts.client_id, &tokens.id_token).ok();
|
||||
if let Err(err) = persist_tokens(
|
||||
opts.codex_home,
|
||||
api_key.clone(),
|
||||
tokens.id_token.clone(),
|
||||
Some(tokens.access_token.clone()),
|
||||
Some(tokens.refresh_token.clone()),
|
||||
) {
|
||||
eprintln!("Persist error: {err}");
|
||||
let _ = req.respond(
|
||||
Response::from_string("Missing authorization code")
|
||||
.with_status_code(400),
|
||||
Response::from_string(format!(
|
||||
"Unable to persist auth file: {err}"
|
||||
))
|
||||
.with_status_code(500),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
match exchange_code_for_tokens(
|
||||
&opts.issuer,
|
||||
&opts.client_id,
|
||||
&redirect_uri,
|
||||
&pkce,
|
||||
&code,
|
||||
) {
|
||||
Ok(tokens) => {
|
||||
// Obtain API key via token-exchange and persist
|
||||
let api_key =
|
||||
obtain_api_key(&opts.issuer, &opts.client_id, &tokens.id_token)
|
||||
.ok();
|
||||
if let Err(err) = persist_tokens(
|
||||
&opts.codex_home,
|
||||
api_key.clone(),
|
||||
tokens.id_token.clone(),
|
||||
Some(tokens.access_token.clone()),
|
||||
Some(tokens.refresh_token.clone()),
|
||||
) {
|
||||
eprintln!("Persist error: {err}");
|
||||
let success_url = compose_success_url(
|
||||
actual_port,
|
||||
opts.issuer,
|
||||
&tokens.id_token,
|
||||
&tokens.access_token,
|
||||
);
|
||||
match tiny_http::Header::from_bytes(
|
||||
&b"Location"[..],
|
||||
success_url.as_bytes(),
|
||||
) {
|
||||
Ok(h) => {
|
||||
let response = tiny_http::Response::empty(302).with_header(h);
|
||||
let _ = req.respond(response);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string(format!(
|
||||
"Unable to persist auth file: {err}"
|
||||
))
|
||||
.with_status_code(500),
|
||||
Response::from_string("Internal Server Error")
|
||||
.with_status_code(500),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let success_url = compose_success_url(
|
||||
actual_port,
|
||||
&opts.issuer,
|
||||
&tokens.id_token,
|
||||
&tokens.access_token,
|
||||
);
|
||||
match tiny_http::Header::from_bytes(
|
||||
&b"Location"[..],
|
||||
success_url.as_bytes(),
|
||||
) {
|
||||
Ok(h) => {
|
||||
let response = tiny_http::Response::empty(302).with_header(h);
|
||||
let _ = req.respond(response);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = req.respond(
|
||||
Response::from_string("Internal Server Error")
|
||||
.with_status_code(500),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!("Token exchange error: {err}");
|
||||
let _ = req.respond(
|
||||
Response::from_string(format!("Token exchange failed: {err}"))
|
||||
.with_status_code(500),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
"/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);
|
||||
Err(err) => {
|
||||
eprintln!("Token exchange error: {err}");
|
||||
let _ = req.respond(
|
||||
Response::from_string(format!("Token exchange failed: {err}"))
|
||||
.with_status_code(500),
|
||||
);
|
||||
}
|
||||
let _ = req.respond(resp);
|
||||
shutdown_flag.store(true, Ordering::SeqCst);
|
||||
return Ok(());
|
||||
}
|
||||
_ => {
|
||||
let _ = req.respond(Response::from_string("Not Found").with_status_code(404));
|
||||
}
|
||||
}
|
||||
"/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);
|
||||
}
|
||||
let _ = req.respond(resp);
|
||||
shutdown_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
_ => {
|
||||
let _ = req.respond(Response::from_string("Not Found").with_status_code(404));
|
||||
}
|
||||
}
|
||||
Err(io::Error::other("Login flow was not completed"))
|
||||
});
|
||||
}
|
||||
|
||||
Ok(LoginServer {
|
||||
auth_url: auth_url.clone(),
|
||||
actual_port,
|
||||
server_handle,
|
||||
shutdown_flag: shutdown_flag_clone,
|
||||
})
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_authorize_url(
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
#![allow(clippy::unwrap_used)]
|
||||
#![expect(clippy::unwrap_used)]
|
||||
use std::net::SocketAddr;
|
||||
use std::net::TcpListener;
|
||||
use std::thread;
|
||||
|
||||
use base64::Engine;
|
||||
use codex_login::LoginServerInfo;
|
||||
use codex_login::ServerOptions;
|
||||
use codex_login::run_login_server;
|
||||
use codex_login::run_server_blocking_with_notify;
|
||||
use tempfile::tempdir;
|
||||
|
||||
// See spawn.rs for details
|
||||
@@ -93,16 +94,21 @@ fn end_to_end_login_flow_persists_auth_json() {
|
||||
// Run server in background
|
||||
let server_home = codex_home.clone();
|
||||
|
||||
let opts = ServerOptions {
|
||||
codex_home: server_home,
|
||||
client_id: codex_login::CLIENT_ID.to_string(),
|
||||
issuer,
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
force_state: Some(state),
|
||||
};
|
||||
let server = run_login_server(opts, None).unwrap();
|
||||
let login_port = server.actual_port;
|
||||
let (tx, rx) = std::sync::mpsc::channel::<LoginServerInfo>();
|
||||
let server_thread = thread::spawn(move || {
|
||||
let opts = ServerOptions {
|
||||
codex_home: &server_home,
|
||||
client_id: codex_login::CLIENT_ID,
|
||||
issuer: &issuer,
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
force_state: Some(state),
|
||||
};
|
||||
run_server_blocking_with_notify(opts, Some(tx), None).unwrap();
|
||||
});
|
||||
|
||||
let server_info = rx.recv().unwrap();
|
||||
let login_port = server_info.actual_port;
|
||||
|
||||
// Simulate browser callback, and follow redirect to /success
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
@@ -114,7 +120,9 @@ fn end_to_end_login_flow_persists_auth_json() {
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
// Wait for server shutdown
|
||||
server.block_until_done().unwrap();
|
||||
server_thread
|
||||
.join()
|
||||
.unwrap_or_else(|_| panic!("server thread panicked"));
|
||||
|
||||
// Validate auth.json
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
@@ -151,23 +159,30 @@ fn creates_missing_codex_home_dir() {
|
||||
|
||||
// Run server in background
|
||||
let server_home = codex_home.clone();
|
||||
let opts = ServerOptions {
|
||||
codex_home: server_home,
|
||||
client_id: codex_login::CLIENT_ID.to_string(),
|
||||
issuer,
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
force_state: Some(state),
|
||||
};
|
||||
let server = run_login_server(opts, None).unwrap();
|
||||
let login_port = server.actual_port;
|
||||
let (tx, rx) = std::sync::mpsc::channel::<LoginServerInfo>();
|
||||
let server_thread = thread::spawn(move || {
|
||||
let opts = ServerOptions {
|
||||
codex_home: &server_home,
|
||||
client_id: codex_login::CLIENT_ID,
|
||||
issuer: &issuer,
|
||||
port: 0,
|
||||
open_browser: false,
|
||||
force_state: Some(state),
|
||||
};
|
||||
run_server_blocking_with_notify(opts, Some(tx), None).unwrap()
|
||||
});
|
||||
|
||||
let server_info = rx.recv().unwrap();
|
||||
let login_port = server_info.actual_port;
|
||||
|
||||
let client = reqwest::blocking::Client::new();
|
||||
let url = format!("http://127.0.0.1:{login_port}/auth/callback?code=abc&state=state2");
|
||||
let resp = client.get(&url).send().unwrap();
|
||||
assert!(resp.status().is_success());
|
||||
|
||||
server.block_until_done().unwrap();
|
||||
server_thread
|
||||
.join()
|
||||
.unwrap_or_else(|_| panic!("server thread panicked"));
|
||||
|
||||
let auth_path = codex_home.join("auth.json");
|
||||
assert!(
|
||||
|
||||
@@ -66,7 +66,6 @@ tokio = { version = "1", features = [
|
||||
"rt-multi-thread",
|
||||
"signal",
|
||||
] }
|
||||
tempfile = "3"
|
||||
tracing = { version = "0.1.41", features = ["log"] }
|
||||
tracing-appender = "0.2.3"
|
||||
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::app_event_sender::AppEventSender;
|
||||
use crate::chatwidget::ChatWidget;
|
||||
use crate::file_search::FileSearchManager;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::insert_history::write_lines;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreen;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreenArgs;
|
||||
@@ -23,9 +22,7 @@ use crossterm::terminal::supports_keyboard_enhancement;
|
||||
use ratatui::layout::Offset;
|
||||
use ratatui::prelude::Backend;
|
||||
use ratatui::text::Line;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
@@ -34,7 +31,6 @@ use std::sync::mpsc::channel;
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
/// Time window for debouncing redraw requests.
|
||||
const REDRAW_DEBOUNCE: Duration = Duration::from_millis(1);
|
||||
@@ -65,7 +61,6 @@ pub(crate) struct App<'a> {
|
||||
file_search: FileSearchManager,
|
||||
|
||||
pending_history_lines: Vec<Line<'static>>,
|
||||
transcript: Vec<Line<'static>>,
|
||||
|
||||
enhanced_keys_supported: bool,
|
||||
|
||||
@@ -75,7 +70,6 @@ pub(crate) struct App<'a> {
|
||||
/// Channel to schedule one-shot animation frames; coalesced by a single
|
||||
/// scheduler thread.
|
||||
frame_schedule_tx: std::sync::mpsc::Sender<Instant>,
|
||||
event_reader_enabled: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
/// Aggregate parameters needed to create a `ChatWidget`, as creation may be
|
||||
@@ -99,7 +93,6 @@ impl App<'_> {
|
||||
|
||||
let (app_event_tx, app_event_rx) = channel();
|
||||
let app_event_tx = AppEventSender::new(app_event_tx);
|
||||
let event_reader_enabled = Arc::new(AtomicBool::new(true));
|
||||
|
||||
let enhanced_keys_supported = supports_keyboard_enhancement().unwrap_or(false);
|
||||
|
||||
@@ -107,13 +100,8 @@ impl App<'_> {
|
||||
// re-publishing the events as AppEvents, as appropriate.
|
||||
{
|
||||
let app_event_tx = app_event_tx.clone();
|
||||
let events_enabled = event_reader_enabled.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
if !events_enabled.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
continue;
|
||||
}
|
||||
// This timeout is necessary to avoid holding the event lock
|
||||
// that crossterm::event::read() acquires. In particular,
|
||||
// reading the cursor position (crossterm::cursor::position())
|
||||
@@ -227,7 +215,6 @@ impl App<'_> {
|
||||
server: conversation_manager,
|
||||
app_event_tx,
|
||||
pending_history_lines: Vec::new(),
|
||||
transcript: Vec::new(),
|
||||
app_event_rx,
|
||||
app_state,
|
||||
config,
|
||||
@@ -235,7 +222,6 @@ impl App<'_> {
|
||||
enhanced_keys_supported,
|
||||
commit_anim_running: Arc::new(AtomicBool::new(false)),
|
||||
frame_schedule_tx: frame_tx,
|
||||
event_reader_enabled,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,16 +235,10 @@ impl App<'_> {
|
||||
|
||||
while let Ok(event) = self.app_event_rx.recv() {
|
||||
match event {
|
||||
AppEvent::InsertHistoryLines(lines) => {
|
||||
self.transcript.extend(lines.clone());
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
self.pending_history_lines.extend(lines);
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
self.transcript.extend(cell.transcript_lines());
|
||||
self.pending_history_lines.extend(cell.display_lines());
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
AppEvent::RequestRedraw => {
|
||||
self.schedule_frame_in(REDRAW_DEBOUNCE);
|
||||
}
|
||||
@@ -319,28 +299,6 @@ impl App<'_> {
|
||||
}
|
||||
// No-op on non-Unix platforms.
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.event_reader_enabled.store(false, Ordering::Relaxed);
|
||||
let mut tmp = NamedTempFile::new()?;
|
||||
write_lines(tmp.as_file_mut(), self.transcript.clone());
|
||||
tmp.flush().ok();
|
||||
let path = tmp.into_temp_path();
|
||||
tui::restore_modes()?;
|
||||
let _ = Command::new("less")
|
||||
.arg("-R")
|
||||
.arg("+G")
|
||||
.arg(path.as_os_str())
|
||||
.status();
|
||||
let _ = path.close();
|
||||
tui::set_modes()?;
|
||||
self.event_reader_enabled.store(true, Ordering::Relaxed);
|
||||
self.app_event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
@@ -541,7 +499,7 @@ impl App<'_> {
|
||||
|
||||
#[cfg(unix)]
|
||||
fn suspend(&mut self, terminal: &mut tui::Tui) -> Result<()> {
|
||||
tui::restore_modes()?;
|
||||
tui::restore()?;
|
||||
// SAFETY: Unix-only code path. We intentionally send SIGTSTP to the
|
||||
// current process group (pid 0) to trigger standard job-control
|
||||
// suspension semantics. This FFI does not involve any raw pointers,
|
||||
|
||||
@@ -5,7 +5,6 @@ use ratatui::text::Line;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::app::ChatWidgetArgs;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::slash_command::SlashCommand;
|
||||
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
@@ -52,8 +51,7 @@ pub(crate) enum AppEvent {
|
||||
matches: Vec<FileMatch>,
|
||||
},
|
||||
|
||||
InsertHistoryLines(Vec<Line<'static>>),
|
||||
InsertHistoryCell(Box<dyn HistoryCell>),
|
||||
InsertHistory(Vec<Line<'static>>),
|
||||
|
||||
StartCommitAnimation,
|
||||
StopCommitAnimation,
|
||||
|
||||
@@ -28,11 +28,6 @@ pub(crate) trait BottomPaneView<'a> {
|
||||
/// Render the view: this will be displayed in place of the composer.
|
||||
fn render(&self, area: Rect, buf: &mut Buffer);
|
||||
|
||||
/// Update the status indicator animated header. Default no-op.
|
||||
fn update_status_header(&mut self, _header: String) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
/// Called when task completes to check if the view should be hidden.
|
||||
fn should_hide_when_task_is_done(&mut self) -> bool {
|
||||
false
|
||||
|
||||
@@ -167,17 +167,6 @@ impl BottomPane<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Update the animated header shown to the left of the brackets in the
|
||||
/// status indicator (defaults to "Working"). This will update the active
|
||||
/// StatusIndicatorView if present; otherwise, if a live overlay is active,
|
||||
/// it will update that. If neither is present, this call is a no-op.
|
||||
pub(crate) fn update_status_header(&mut self, header: String) {
|
||||
if let Some(view) = self.active_view.as_mut() {
|
||||
view.update_status_header(header.clone());
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn show_ctrl_c_quit_hint(&mut self) {
|
||||
self.ctrl_c_quit_hint = true;
|
||||
self.composer
|
||||
|
||||
@@ -23,17 +23,9 @@ impl StatusIndicatorView {
|
||||
pub fn update_text(&mut self, text: String) {
|
||||
self.view.update_text(text);
|
||||
}
|
||||
|
||||
pub fn update_header(&mut self, header: String) {
|
||||
self.view.update_header(header);
|
||||
}
|
||||
}
|
||||
|
||||
impl BottomPaneView<'_> for StatusIndicatorView {
|
||||
fn update_status_header(&mut self, header: String) {
|
||||
self.update_header(header);
|
||||
}
|
||||
|
||||
fn should_hide_when_task_is_done(&mut self) -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tracing::debug;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
@@ -43,6 +44,7 @@ use crate::bottom_pane::BottomPane;
|
||||
use crate::bottom_pane::BottomPaneParams;
|
||||
use crate::bottom_pane::CancellationEvent;
|
||||
use crate::bottom_pane::InputResult;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::CommandOutput;
|
||||
use crate::history_cell::ExecCell;
|
||||
@@ -58,7 +60,6 @@ use crate::streaming::controller::AppEventHistorySink;
|
||||
use crate::streaming::controller::StreamController;
|
||||
use codex_core::ConversationManager;
|
||||
use codex_file_search::FileMatch;
|
||||
use uuid::Uuid;
|
||||
|
||||
// Track information about an in-flight exec command.
|
||||
struct RunningCommand {
|
||||
@@ -77,6 +78,8 @@ pub(crate) struct ChatWidget<'a> {
|
||||
last_token_usage: TokenUsage,
|
||||
// Stream lifecycle controller
|
||||
stream: StreamController,
|
||||
// Track the most recently active stream kind in the current turn
|
||||
last_stream_kind: Option<StreamKind>,
|
||||
running_commands: HashMap<String, RunningCommand>,
|
||||
pending_exec_completions: Vec<(Vec<String>, Vec<ParsedCommand>, CommandOutput)>,
|
||||
task_complete_pending: bool,
|
||||
@@ -84,9 +87,6 @@ pub(crate) struct ChatWidget<'a> {
|
||||
interrupts: InterruptManager,
|
||||
// Whether a redraw is needed after handling the current event
|
||||
needs_redraw: bool,
|
||||
// Accumulates the current reasoning block text to extract a header
|
||||
reasoning_buffer: String,
|
||||
full_reasoning_buffer: String,
|
||||
session_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ struct UserMessage {
|
||||
image_paths: Vec<PathBuf>,
|
||||
}
|
||||
|
||||
use crate::streaming::StreamKind;
|
||||
|
||||
impl From<String> for UserMessage {
|
||||
fn from(text: String) -> Self {
|
||||
Self {
|
||||
@@ -119,14 +121,14 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
fn flush_answer_stream_with_separator(&mut self) {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
let _ = self.stream.finalize(true, &sink);
|
||||
let _ = self.stream.finalize(StreamKind::Answer, true, &sink);
|
||||
}
|
||||
// --- Small event handlers ---
|
||||
fn on_session_configured(&mut self, event: codex_core::protocol::SessionConfiguredEvent) {
|
||||
self.session_id = Some(event.session_id);
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
self.session_id = Some(event.session_id);
|
||||
self.add_to_history(history_cell::new_session_info(&self.config, event, true));
|
||||
self.add_to_history(&history_cell::new_session_info(&self.config, event, true));
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
self.submit_user_message(user_message);
|
||||
}
|
||||
@@ -136,48 +138,30 @@ impl ChatWidget<'_> {
|
||||
fn on_agent_message(&mut self, message: String) {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
let finished = self.stream.apply_final_answer(&message, &sink);
|
||||
self.last_stream_kind = Some(StreamKind::Answer);
|
||||
self.handle_if_stream_finished(finished);
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
|
||||
fn on_agent_message_delta(&mut self, delta: String) {
|
||||
self.handle_streaming_delta(delta);
|
||||
self.handle_streaming_delta(StreamKind::Answer, delta);
|
||||
}
|
||||
|
||||
fn on_agent_reasoning_delta(&mut self, delta: String) {
|
||||
// For reasoning deltas, do not stream to history. Accumulate the
|
||||
// current reasoning block and extract the first bold element
|
||||
// (between **/**) as the chunk header. Show this header as status.
|
||||
self.reasoning_buffer.push_str(&delta);
|
||||
|
||||
if let Some(header) = extract_first_bold(&self.reasoning_buffer) {
|
||||
// Update the shimmer header to the extracted reasoning chunk header.
|
||||
self.bottom_pane.update_status_header(header);
|
||||
} else {
|
||||
// Fallback while we don't yet have a bold header: leave existing header as-is.
|
||||
}
|
||||
self.mark_needs_redraw();
|
||||
self.handle_streaming_delta(StreamKind::Reasoning, delta);
|
||||
}
|
||||
|
||||
fn on_agent_reasoning_final(&mut self) {
|
||||
// Clear the reasoning buffer at the end of a reasoning block.
|
||||
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
|
||||
if !self.full_reasoning_buffer.is_empty() {
|
||||
self.add_to_history(history_cell::new_reasoning_block(
|
||||
self.full_reasoning_buffer.clone(),
|
||||
&self.config,
|
||||
));
|
||||
}
|
||||
self.reasoning_buffer.clear();
|
||||
self.full_reasoning_buffer.clear();
|
||||
fn on_agent_reasoning_final(&mut self, text: String) {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
let finished = self.stream.apply_final_reasoning(&text, &sink);
|
||||
self.last_stream_kind = Some(StreamKind::Reasoning);
|
||||
self.handle_if_stream_finished(finished);
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
|
||||
fn on_reasoning_section_break(&mut self) {
|
||||
// Start a new reasoning block for header extraction.
|
||||
self.full_reasoning_buffer.push_str(&self.reasoning_buffer);
|
||||
self.full_reasoning_buffer.push_str("\n\n");
|
||||
self.reasoning_buffer.clear();
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
self.stream.insert_reasoning_section_break(&sink);
|
||||
}
|
||||
|
||||
// Raw reasoning uses the same flow as summarized reasoning
|
||||
@@ -186,8 +170,7 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.bottom_pane.set_task_running(true);
|
||||
self.stream.reset_headers_for_new_turn();
|
||||
self.full_reasoning_buffer.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
self.last_stream_kind = None;
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
|
||||
@@ -196,7 +179,9 @@ impl ChatWidget<'_> {
|
||||
// without emitting stray headers for other streams.
|
||||
if self.stream.is_write_cycle_active() {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
let _ = self.stream.finalize(true, &sink);
|
||||
if let Some(kind) = self.last_stream_kind {
|
||||
let _ = self.stream.finalize(kind, true, &sink);
|
||||
}
|
||||
}
|
||||
// Mark task stopped and request redraw now that all content is in history.
|
||||
self.bottom_pane.set_task_running(false);
|
||||
@@ -215,7 +200,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn on_error(&mut self, message: String) {
|
||||
self.add_to_history(history_cell::new_error_event(message));
|
||||
self.add_to_history(&history_cell::new_error_event(message));
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.running_commands.clear();
|
||||
self.stream.clear_all();
|
||||
@@ -223,7 +208,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn on_plan_update(&mut self, update: codex_core::plan_tool::UpdatePlanArgs) {
|
||||
self.add_to_history(history_cell::new_plan_update(update));
|
||||
self.add_to_history(&history_cell::new_plan_update(update));
|
||||
}
|
||||
|
||||
fn on_exec_approval_request(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||||
@@ -258,7 +243,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
fn on_patch_apply_begin(&mut self, event: PatchApplyBeginEvent) {
|
||||
self.add_to_history(history_cell::new_patch_event(
|
||||
self.add_to_history(&history_cell::new_patch_event(
|
||||
PatchEventType::ApplyBegin {
|
||||
auto_approved: event.auto_approved,
|
||||
},
|
||||
@@ -358,9 +343,10 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn handle_streaming_delta(&mut self, delta: String) {
|
||||
fn handle_streaming_delta(&mut self, kind: StreamKind, delta: String) {
|
||||
let sink = AppEventHistorySink(self.app_event_tx.clone());
|
||||
self.stream.begin(&sink);
|
||||
self.stream.begin(kind, &sink);
|
||||
self.last_stream_kind = Some(kind);
|
||||
self.stream.push_and_maybe_commit(&delta, &sink);
|
||||
self.mark_needs_redraw();
|
||||
}
|
||||
@@ -385,7 +371,7 @@ impl ChatWidget<'_> {
|
||||
self.active_exec_cell = None;
|
||||
let pending = std::mem::take(&mut self.pending_exec_completions);
|
||||
for (command, parsed, output) in pending {
|
||||
self.add_to_history(history_cell::new_completed_exec_command(
|
||||
self.add_to_history(&history_cell::new_completed_exec_command(
|
||||
command, parsed, output,
|
||||
));
|
||||
}
|
||||
@@ -397,14 +383,25 @@ impl ChatWidget<'_> {
|
||||
event: codex_core::protocol::PatchApplyEndEvent,
|
||||
) {
|
||||
if event.success {
|
||||
self.add_to_history(history_cell::new_patch_apply_success(event.stdout));
|
||||
self.add_to_history(&history_cell::new_patch_apply_success(event.stdout));
|
||||
} else {
|
||||
self.add_to_history(history_cell::new_patch_apply_failure(event.stderr));
|
||||
self.add_to_history(&history_cell::new_patch_apply_failure(event.stderr));
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn handle_exec_approval_now(&mut self, id: String, ev: ExecApprovalRequestEvent) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
// Log a background summary immediately so the history is chronological.
|
||||
let cmdline = strip_bash_lc_and_escape(&ev.command);
|
||||
let text = format!(
|
||||
"command requires approval:\n$ {cmdline}{reason}",
|
||||
reason = ev
|
||||
.reason
|
||||
.as_ref()
|
||||
.map(|r| format!("\n{r}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
self.add_to_history(&history_cell::new_background_event(text));
|
||||
|
||||
let request = ApprovalRequest::Exec {
|
||||
id,
|
||||
@@ -421,7 +418,7 @@ impl ChatWidget<'_> {
|
||||
ev: ApplyPatchApprovalRequestEvent,
|
||||
) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.add_to_history(history_cell::new_patch_event(
|
||||
self.add_to_history(&history_cell::new_patch_event(
|
||||
PatchEventType::ApprovalRequest,
|
||||
ev.changes.clone(),
|
||||
));
|
||||
@@ -463,26 +460,20 @@ impl ChatWidget<'_> {
|
||||
|
||||
pub(crate) fn handle_mcp_begin_now(&mut self, ev: McpToolCallBeginEvent) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
self.add_to_history(history_cell::new_active_mcp_tool_call(ev.invocation));
|
||||
self.add_to_history(&history_cell::new_active_mcp_tool_call(ev.invocation));
|
||||
}
|
||||
pub(crate) fn handle_mcp_end_now(&mut self, ev: McpToolCallEndEvent) {
|
||||
self.flush_answer_stream_with_separator();
|
||||
if let Some(cell) =
|
||||
history_cell::try_new_completed_mcp_tool_call_with_image_output(&ev.result)
|
||||
{
|
||||
self.add_to_history(cell);
|
||||
} else {
|
||||
self.add_to_history(history_cell::new_completed_mcp_tool_call(
|
||||
80,
|
||||
ev.invocation,
|
||||
ev.duration,
|
||||
ev.result
|
||||
.as_ref()
|
||||
.map(|r| !r.is_error.unwrap_or(false))
|
||||
.unwrap_or(false),
|
||||
ev.result,
|
||||
));
|
||||
}
|
||||
self.add_to_history(&*history_cell::new_completed_mcp_tool_call(
|
||||
80,
|
||||
ev.invocation,
|
||||
ev.duration,
|
||||
ev.result
|
||||
.as_ref()
|
||||
.map(|r| !r.is_error.unwrap_or(false))
|
||||
.unwrap_or(false),
|
||||
ev.result,
|
||||
));
|
||||
}
|
||||
fn interrupt_running_task(&mut self) {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
@@ -534,13 +525,12 @@ impl ChatWidget<'_> {
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
stream: StreamController::new(config),
|
||||
last_stream_kind: None,
|
||||
running_commands: HashMap::new(),
|
||||
pending_exec_completions: Vec::new(),
|
||||
task_complete_pending: false,
|
||||
interrupts: InterruptManager::new(),
|
||||
needs_redraw: false,
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
}
|
||||
}
|
||||
@@ -573,14 +563,14 @@ impl ChatWidget<'_> {
|
||||
fn flush_active_exec_cell(&mut self) {
|
||||
if let Some(active) = self.active_exec_cell.take() {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(active)));
|
||||
.send(AppEvent::InsertHistory(active.display_lines()));
|
||||
}
|
||||
}
|
||||
|
||||
fn add_to_history(&mut self, cell: impl HistoryCell + 'static) {
|
||||
fn add_to_history(&mut self, cell: &dyn HistoryCell) {
|
||||
self.flush_active_exec_cell();
|
||||
self.app_event_tx
|
||||
.send(AppEvent::InsertHistoryCell(Box::new(cell)));
|
||||
.send(AppEvent::InsertHistory(cell.display_lines()));
|
||||
}
|
||||
|
||||
fn submit_user_message(&mut self, user_message: UserMessage) {
|
||||
@@ -616,7 +606,7 @@ impl ChatWidget<'_> {
|
||||
|
||||
// Only show the text portion in conversation history.
|
||||
if !text.is_empty() {
|
||||
self.add_to_history(history_cell::new_user_prompt(text.clone()));
|
||||
self.add_to_history(&history_cell::new_user_prompt(text.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -644,9 +634,9 @@ impl ChatWidget<'_> {
|
||||
| EventMsg::AgentReasoningRawContentDelta(AgentReasoningRawContentDeltaEvent {
|
||||
delta,
|
||||
}) => self.on_agent_reasoning_delta(delta),
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { .. })
|
||||
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { .. }) => {
|
||||
self.on_agent_reasoning_final()
|
||||
EventMsg::AgentReasoning(AgentReasoningEvent { text })
|
||||
| EventMsg::AgentReasoningRawContent(AgentReasoningRawContentEvent { text }) => {
|
||||
self.on_agent_reasoning_final(text)
|
||||
}
|
||||
EventMsg::AgentReasoningSectionBreak(_) => self.on_reasoning_section_break(),
|
||||
EventMsg::TaskStarted => self.on_task_started(),
|
||||
@@ -682,11 +672,11 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn add_diff_output(&mut self, diff_output: String) {
|
||||
self.add_to_history(history_cell::new_diff_output(diff_output.clone()));
|
||||
self.add_to_history(&history_cell::new_diff_output(diff_output.clone()));
|
||||
}
|
||||
|
||||
pub(crate) fn add_status_output(&mut self) {
|
||||
self.add_to_history(history_cell::new_status_output(
|
||||
self.add_to_history(&history_cell::new_status_output(
|
||||
&self.config,
|
||||
&self.total_token_usage,
|
||||
&self.session_id,
|
||||
@@ -694,7 +684,7 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
|
||||
pub(crate) fn add_prompts_output(&mut self) {
|
||||
self.add_to_history(history_cell::new_prompts_output());
|
||||
self.add_to_history(&history_cell::new_prompts_output());
|
||||
}
|
||||
|
||||
/// Forward file-search results to the bottom pane.
|
||||
@@ -805,35 +795,5 @@ fn add_token_usage(current_usage: &TokenUsage, new_usage: &TokenUsage) -> TokenU
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the first bold (Markdown) element in the form **...** from `s`.
|
||||
// Returns the inner text if found; otherwise `None`.
|
||||
fn extract_first_bold(s: &str) -> Option<String> {
|
||||
let bytes = s.as_bytes();
|
||||
let mut i = 0usize;
|
||||
while i + 1 < bytes.len() {
|
||||
if bytes[i] == b'*' && bytes[i + 1] == b'*' {
|
||||
let start = i + 2;
|
||||
let mut j = start;
|
||||
while j + 1 < bytes.len() {
|
||||
if bytes[j] == b'*' && bytes[j + 1] == b'*' {
|
||||
// Found closing **
|
||||
let inner = &s[start..j];
|
||||
let trimmed = inner.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(trimmed.to_string());
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
// No closing; stop searching (wait for more deltas)
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
assertion_line: 886
|
||||
expression: combined
|
||||
---
|
||||
thinking
|
||||
I will first analyze the request.
|
||||
|
||||
codex
|
||||
Here is the result.
|
||||
|
||||
@@ -2,5 +2,8 @@
|
||||
source: tui/src/chatwidget/tests.rs
|
||||
expression: combined
|
||||
---
|
||||
thinking
|
||||
I will first analyze the request.
|
||||
|
||||
codex
|
||||
Here is the result.
|
||||
|
||||
@@ -135,13 +135,12 @@ fn make_chatwidget_manual() -> (
|
||||
total_token_usage: TokenUsage::default(),
|
||||
last_token_usage: TokenUsage::default(),
|
||||
stream: StreamController::new(cfg),
|
||||
last_stream_kind: None,
|
||||
running_commands: HashMap::new(),
|
||||
pending_exec_completions: Vec::new(),
|
||||
task_complete_pending: false,
|
||||
interrupts: InterruptManager::new(),
|
||||
needs_redraw: false,
|
||||
reasoning_buffer: String::new(),
|
||||
full_reasoning_buffer: String::new(),
|
||||
session_id: None,
|
||||
};
|
||||
(widget, rx, op_rx)
|
||||
@@ -152,10 +151,8 @@ fn drain_insert_history(
|
||||
) -> Vec<Vec<ratatui::text::Line<'static>>> {
|
||||
let mut out = Vec::new();
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
match ev {
|
||||
AppEvent::InsertHistoryLines(lines) => out.push(lines),
|
||||
AppEvent::InsertHistoryCell(cell) => out.push(cell.display_lines()),
|
||||
_ => {}
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
out.push(lines);
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -323,25 +320,13 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
let ev: Event = serde_json::from_value(payload.clone()).expect("parse");
|
||||
chat.handle_codex_event(ev);
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
match app_ev {
|
||||
AppEvent::InsertHistoryLines(lines) => {
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let lines = cell.display_lines();
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
if let AppEvent::InsertHistory(lines) = app_ev {
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -351,25 +336,13 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
if variant == "CommitTick" {
|
||||
chat.on_commit_tick();
|
||||
while let Ok(app_ev) = rx.try_recv() {
|
||||
match app_ev {
|
||||
AppEvent::InsertHistoryLines(lines) => {
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
let lines = cell.display_lines();
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
if let AppEvent::InsertHistory(lines) = app_ev {
|
||||
transcript.push_str(&lines_to_single_string(&lines));
|
||||
crate::insert_history::insert_history_lines_to_writer(
|
||||
&mut terminal,
|
||||
&mut ansi,
|
||||
lines,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -386,11 +359,6 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
.expect("read ideal-binary-response.txt");
|
||||
// Normalize line endings for Windows vs. Unix checkouts
|
||||
let ideal = ideal.replace("\r\n", "\n");
|
||||
let ideal_first_line = ideal
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
// Build the final VT100 visual by parsing the ANSI stream. Trim trailing spaces per line
|
||||
// and drop trailing empty lines so the shape matches the ideal fixture exactly.
|
||||
@@ -416,68 +384,22 @@ async fn binary_size_transcript_matches_ideal_fixture() {
|
||||
while lines.last().is_some_and(|l| l.is_empty()) {
|
||||
lines.pop();
|
||||
}
|
||||
// Compare only after the last session banner marker. Skip the transient
|
||||
// 'thinking' header if present, and start from the first non-empty line
|
||||
// of content that follows.
|
||||
// Compare only after the last session banner marker, and start at the next 'thinking' line.
|
||||
const MARKER_PREFIX: &str = ">_ You are using OpenAI Codex in ";
|
||||
let last_marker_line_idx = lines
|
||||
.iter()
|
||||
.rposition(|l| l.starts_with(MARKER_PREFIX))
|
||||
.expect("marker not found in visible output");
|
||||
// Anchor to the first ideal line if present; otherwise use heuristics.
|
||||
let start_idx = (last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| lines[idx].trim_start() == ideal_first_line)
|
||||
.or_else(|| {
|
||||
// Prefer the first assistant content line (blockquote '>' prefix) after the marker.
|
||||
(last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| lines[idx].trim_start().starts_with('>'))
|
||||
})
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback: first non-empty, non-'thinking' line
|
||||
(last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| {
|
||||
let t = lines[idx].trim_start();
|
||||
!t.is_empty() && t != "thinking"
|
||||
})
|
||||
.expect("no content line found after marker")
|
||||
});
|
||||
let thinking_line_idx = (last_marker_line_idx + 1..lines.len())
|
||||
.find(|&idx| lines[idx].trim_start() == "thinking")
|
||||
.expect("no 'thinking' line found after marker");
|
||||
|
||||
let mut compare_lines: Vec<String> = Vec::new();
|
||||
// Ensure the first line is trimmed-left to match the fixture shape.
|
||||
compare_lines.push(lines[start_idx].trim_start().to_string());
|
||||
compare_lines.extend(lines[(start_idx + 1)..].iter().cloned());
|
||||
// Ensure the first line is exactly 'thinking' without leading spaces to match the fixture
|
||||
compare_lines.push(lines[thinking_line_idx].trim_start().to_string());
|
||||
compare_lines.extend(lines[(thinking_line_idx + 1)..].iter().cloned());
|
||||
let visible_after = compare_lines.join("\n");
|
||||
|
||||
// Normalize: drop a leading 'thinking' line if present in either side to
|
||||
// avoid coupling to whether the reasoning header is rendered in history.
|
||||
fn drop_leading_thinking(s: &str) -> String {
|
||||
let mut it = s.lines();
|
||||
let first = it.next();
|
||||
let rest = it.collect::<Vec<_>>().join("\n");
|
||||
if first.is_some_and(|l| l.trim() == "thinking") {
|
||||
rest
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
let visible_after = drop_leading_thinking(&visible_after);
|
||||
let ideal = drop_leading_thinking(&ideal);
|
||||
|
||||
// Normalize: strip leading Markdown blockquote markers ('>' or '> ') which
|
||||
// may be present in rendered transcript lines but not in the ideal text.
|
||||
fn strip_blockquotes(s: &str) -> String {
|
||||
s.lines()
|
||||
.map(|l| {
|
||||
l.strip_prefix("> ")
|
||||
.or_else(|| l.strip_prefix('>'))
|
||||
.unwrap_or(l)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
let visible_after = strip_blockquotes(&visible_after);
|
||||
let ideal = strip_blockquotes(&ideal);
|
||||
|
||||
// Optionally update the fixture when env var is set
|
||||
if std::env::var("UPDATE_IDEAL").as_deref() == Ok("1") {
|
||||
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
||||
@@ -808,7 +730,7 @@ fn plan_update_renders_history_cell() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||
fn headers_emitted_on_stream_begin_for_answer_and_reasoning() {
|
||||
let (mut chat, rx, _op_rx) = make_chatwidget_manual();
|
||||
|
||||
// Answer: no header until a newline commit
|
||||
@@ -820,7 +742,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||
});
|
||||
let mut saw_codex_pre = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryLines(lines) = ev {
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
let s = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
@@ -848,7 +770,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||
chat.on_commit_tick();
|
||||
let mut saw_codex_post = false;
|
||||
while let Ok(ev) = rx.try_recv() {
|
||||
if let AppEvent::InsertHistoryLines(lines) = ev {
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
let s = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
@@ -866,7 +788,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||
"expected 'codex' header to be emitted after first newline commit"
|
||||
);
|
||||
|
||||
// Reasoning: do NOT emit a history header; status text is updated instead
|
||||
// Reasoning: header immediately
|
||||
let (mut chat2, rx2, _op_rx2) = make_chatwidget_manual();
|
||||
chat2.handle_codex_event(Event {
|
||||
id: "sub-b".into(),
|
||||
@@ -876,7 +798,7 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||
});
|
||||
let mut saw_thinking = false;
|
||||
while let Ok(ev) = rx2.try_recv() {
|
||||
if let AppEvent::InsertHistoryLines(lines) = ev {
|
||||
if let AppEvent::InsertHistory(lines) = ev {
|
||||
let s = lines
|
||||
.iter()
|
||||
.flat_map(|l| l.spans.iter())
|
||||
@@ -890,8 +812,8 @@ fn headers_emitted_on_stream_begin_for_answer_and_not_for_reasoning() {
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
!saw_thinking,
|
||||
"reasoning deltas should not emit history headers"
|
||||
saw_thinking,
|
||||
"expected 'thinking' header to be emitted at stream start"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,11 +2,8 @@ use crate::colors::LIGHT_BLUE;
|
||||
use crate::diff_render::create_diff_summary;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::markdown::append_markdown;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use crate::user_approval_widget::ApprovalRequest;
|
||||
use crate::user_approval_widget::to_command_display;
|
||||
use base64::Engine;
|
||||
use codex_ansi_escape::ansi_escape_line;
|
||||
use codex_common::create_config_summary_entries;
|
||||
@@ -18,7 +15,6 @@ use codex_core::plan_tool::StepStatus;
|
||||
use codex_core::plan_tool::UpdatePlanArgs;
|
||||
use codex_core::protocol::FileChange;
|
||||
use codex_core::protocol::McpInvocation;
|
||||
use codex_core::protocol::ReviewDecision;
|
||||
use codex_core::protocol::SandboxPolicy;
|
||||
use codex_core::protocol::SessionConfiguredEvent;
|
||||
use codex_core::protocol::TokenUsage;
|
||||
@@ -43,7 +39,7 @@ use std::time::Instant;
|
||||
use tracing::error;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CommandOutput {
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) stdout: String,
|
||||
@@ -58,13 +54,9 @@ pub(crate) enum PatchEventType {
|
||||
/// 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.
|
||||
pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync {
|
||||
pub(crate) trait HistoryCell {
|
||||
fn display_lines(&self) -> Vec<Line<'static>>;
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
self.display_lines()
|
||||
}
|
||||
|
||||
fn desired_height(&self, width: u16) -> u16 {
|
||||
Paragraph::new(Text::from(self.display_lines()))
|
||||
.wrap(Wrap { trim: false })
|
||||
@@ -74,7 +66,6 @@ pub(crate) trait HistoryCell: std::fmt::Debug + Send + Sync {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PlainHistoryCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
}
|
||||
@@ -85,22 +76,6 @@ impl HistoryCell for PlainHistoryCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct TranscriptOnlyHistoryCell {
|
||||
lines: Vec<Line<'static>>,
|
||||
}
|
||||
|
||||
impl HistoryCell for TranscriptOnlyHistoryCell {
|
||||
fn display_lines(&self) -> Vec<Line<'static>> {
|
||||
vec![]
|
||||
}
|
||||
|
||||
fn transcript_lines(&self) -> Vec<Line<'static>> {
|
||||
self.lines.clone()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ExecCell {
|
||||
pub(crate) command: Vec<String>,
|
||||
pub(crate) parsed: Vec<ParsedCommand>,
|
||||
@@ -126,8 +101,7 @@ impl WidgetRef for &ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct CompletedMcpToolCallWithImageOutput {
|
||||
struct CompletedMcpToolCallWithImageOutput {
|
||||
_image: DynamicImage,
|
||||
}
|
||||
impl HistoryCell for CompletedMcpToolCallWithImageOutput {
|
||||
@@ -162,6 +136,14 @@ fn pretty_provider_name(id: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn new_background_event(message: String) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("event".dim()));
|
||||
lines.extend(message.lines().map(|line| ansi_escape_line(line).dim()));
|
||||
lines.push(Line::from(""));
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_session_info(
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
@@ -369,7 +351,7 @@ pub(crate) fn new_active_mcp_tool_call(invocation: McpInvocation) -> PlainHistor
|
||||
|
||||
/// If the first content is an image, return a new cell with the image.
|
||||
/// TODO(rgwood-dd): Handle images properly even if they're not the first result.
|
||||
pub fn try_new_completed_mcp_tool_call_with_image_output(
|
||||
fn try_new_completed_mcp_tool_call_with_image_output(
|
||||
result: &Result<mcp_types::CallToolResult, String>,
|
||||
) -> Option<CompletedMcpToolCallWithImageOutput> {
|
||||
match result {
|
||||
@@ -413,7 +395,11 @@ pub(crate) fn new_completed_mcp_tool_call(
|
||||
duration: Duration,
|
||||
success: bool,
|
||||
result: Result<mcp_types::CallToolResult, String>,
|
||||
) -> PlainHistoryCell {
|
||||
) -> Box<dyn HistoryCell> {
|
||||
if let Some(cell) = try_new_completed_mcp_tool_call_with_image_output(&result) {
|
||||
return Box::new(cell);
|
||||
}
|
||||
|
||||
let duration = format_duration(duration);
|
||||
let status_str = if success { "success" } else { "failed" };
|
||||
let title_line = Line::from(vec![
|
||||
@@ -481,7 +467,7 @@ pub(crate) fn new_completed_mcp_tool_call(
|
||||
}
|
||||
};
|
||||
|
||||
PlainHistoryCell { lines }
|
||||
Box::new(PlainHistoryCell { lines })
|
||||
}
|
||||
|
||||
pub(crate) fn new_diff_output(message: String) -> PlainHistoryCell {
|
||||
@@ -917,94 +903,6 @@ fn format_mcp_invocation<'a>(invocation: McpInvocation) -> Line<'a> {
|
||||
Line::from(invocation_spans)
|
||||
}
|
||||
|
||||
pub(crate) fn new_exec_approval_decision(
|
||||
approval_request: &ApprovalRequest,
|
||||
decision: codex_core::protocol::ReviewDecision,
|
||||
feedback: String,
|
||||
) -> PlainHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
match approval_request {
|
||||
ApprovalRequest::Exec { command, .. } => {
|
||||
let cmd = strip_bash_lc_and_escape(command);
|
||||
let mut cmd_span: Span = cmd.clone().into();
|
||||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
||||
|
||||
// Result line based on decision.
|
||||
match decision {
|
||||
ReviewDecision::Approved => {
|
||||
lines.extend(to_command_display(
|
||||
vec![
|
||||
"✔ ".fg(Color::Green),
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
],
|
||||
cmd,
|
||||
vec![" this time".bold()],
|
||||
));
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => {
|
||||
lines.extend(to_command_display(
|
||||
vec![
|
||||
"✔ ".fg(Color::Green),
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
"codex to run ".into(),
|
||||
],
|
||||
cmd,
|
||||
vec![" every time this session".bold()],
|
||||
));
|
||||
}
|
||||
ReviewDecision::Denied => {
|
||||
lines.extend(to_command_display(
|
||||
vec![
|
||||
"✗ ".fg(Color::Red),
|
||||
"You ".into(),
|
||||
"did not approve".bold(),
|
||||
" codex to run ".into(),
|
||||
],
|
||||
cmd,
|
||||
vec![],
|
||||
));
|
||||
}
|
||||
ReviewDecision::Abort => {
|
||||
lines.extend(to_command_display(
|
||||
vec![
|
||||
"✗ ".fg(Color::Red),
|
||||
"You ".into(),
|
||||
"canceled".bold(),
|
||||
" the request to run ".into(),
|
||||
],
|
||||
cmd,
|
||||
vec![],
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
ApprovalRequest::ApplyPatch { .. } => {
|
||||
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
|
||||
}
|
||||
}
|
||||
if !feedback.trim().is_empty() {
|
||||
lines.push(Line::from("feedback:"));
|
||||
for l in feedback.lines() {
|
||||
lines.push(Line::from(l.to_string()));
|
||||
}
|
||||
}
|
||||
PlainHistoryCell { lines }
|
||||
}
|
||||
|
||||
pub(crate) fn new_reasoning_block(
|
||||
full_reasoning_buffer: String,
|
||||
config: &Config,
|
||||
) -> TranscriptOnlyHistoryCell {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
lines.push(Line::from("thinking".magenta().italic()));
|
||||
append_markdown(&full_reasoning_buffer, &mut lines, config);
|
||||
lines.push(Line::from(""));
|
||||
TranscriptOnlyHistoryCell { lines }
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -109,13 +109,6 @@ pub fn insert_history_lines_to_writer<B, W>(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_lines(writer: &mut impl Write, lines: Vec<Line>) {
|
||||
for line in lines {
|
||||
queue!(writer, Print("\r\n")).ok();
|
||||
write_spans(writer, line.iter()).ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SetScrollRegion(pub std::ops::Range<u16>);
|
||||
|
||||
|
||||
@@ -288,7 +288,7 @@ fn run_ratatui_app(
|
||||
reason = "TUI should no longer be displayed, so we can write to stderr."
|
||||
)]
|
||||
fn restore() {
|
||||
if let Err(err) = tui::restore_modes() {
|
||||
if let Err(err) = tui::restore() {
|
||||
eprintln!(
|
||||
"failed to restore terminal. Run `reset` or restart your terminal to recover: {err}"
|
||||
);
|
||||
|
||||
@@ -45,6 +45,22 @@ impl MarkdownStreamCollector {
|
||||
self.buffer.push_str(delta);
|
||||
}
|
||||
|
||||
/// Insert a paragraph/section separator if one is not already present at the
|
||||
/// end of the buffer. Ensures the next content starts after a blank line.
|
||||
pub fn insert_section_break(&mut self) {
|
||||
if self.buffer.is_empty() {
|
||||
return;
|
||||
}
|
||||
if self.buffer.ends_with("\n\n") {
|
||||
return;
|
||||
}
|
||||
if self.buffer.ends_with('\n') {
|
||||
self.buffer.push('\n');
|
||||
} else {
|
||||
self.buffer.push_str("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the full buffer and return only the newly completed logical lines
|
||||
/// since the last commit. When the buffer does not end with a newline, the
|
||||
/// final rendered line is considered incomplete and is not emitted.
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
use codex_login::CLIENT_ID;
|
||||
use codex_login::ServerOptions;
|
||||
use codex_login::run_login_server;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
@@ -25,10 +22,6 @@ use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::StepStateProvider;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::thread::JoinHandle;
|
||||
|
||||
use super::onboarding_screen::StepState;
|
||||
// no additional imports
|
||||
@@ -46,14 +39,12 @@ pub(crate) enum SignInState {
|
||||
#[derive(Debug)]
|
||||
/// Used to manage the lifecycle of SpawnedLogin and ensure it gets cleaned up.
|
||||
pub(crate) struct ContinueInBrowserState {
|
||||
auth_url: String,
|
||||
shutdown_flag: Option<Arc<AtomicBool>>,
|
||||
_login_wait_handle: Option<JoinHandle<()>>,
|
||||
login_child: Option<codex_login::SpawnedLogin>,
|
||||
}
|
||||
impl Drop for ContinueInBrowserState {
|
||||
fn drop(&mut self) {
|
||||
if let Some(flag) = &self.shutdown_flag {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
if let Some(child) = &self.login_child {
|
||||
child.cancel();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,12 +184,16 @@ impl AuthModeWidget {
|
||||
let mut lines = vec![Line::from(spans), Line::from("")];
|
||||
|
||||
if let SignInState::ChatGptContinueInBrowser(state) = &self.sign_in_state {
|
||||
if !state.auth_url.is_empty() {
|
||||
if let Some(url) = state
|
||||
.login_child
|
||||
.as_ref()
|
||||
.and_then(|child| child.get_login_url())
|
||||
{
|
||||
lines.push(Line::from(" If the link doesn't open automatically, open the following link to authenticate:"));
|
||||
lines.push(Line::from(vec![
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
state.auth_url.as_str(),
|
||||
url,
|
||||
Style::default()
|
||||
.fg(LIGHT_BLUE)
|
||||
.add_modifier(Modifier::UNDERLINED),
|
||||
@@ -294,17 +289,12 @@ impl AuthModeWidget {
|
||||
|
||||
fn start_chatgpt_login(&mut self) {
|
||||
self.error = None;
|
||||
let opts = ServerOptions::new(self.codex_home.clone(), CLIENT_ID.to_string());
|
||||
let server = run_login_server(opts, None);
|
||||
match server {
|
||||
match codex_login::spawn_login_with_chatgpt(&self.codex_home) {
|
||||
Ok(child) => {
|
||||
let auth_url = child.auth_url.clone();
|
||||
let shutdown_flag = child.shutdown_flag.clone();
|
||||
self.spawn_completion_poller(child.clone());
|
||||
self.sign_in_state =
|
||||
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||
auth_url,
|
||||
shutdown_flag: Some(shutdown_flag),
|
||||
_login_wait_handle: Some(self.spawn_completion_poller(child)),
|
||||
login_child: Some(child),
|
||||
});
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
@@ -326,17 +316,23 @@ impl AuthModeWidget {
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
|
||||
fn spawn_completion_poller(&self, child: codex_login::LoginServer) -> JoinHandle<()> {
|
||||
fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
|
||||
let event_tx = self.event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
if let Ok(()) = child.block_until_done() {
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
|
||||
} else {
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Err(
|
||||
"login failed".to_string()
|
||||
)));
|
||||
loop {
|
||||
if let Some(success) = child.get_auth_result() {
|
||||
if success {
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
|
||||
} else {
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Err(
|
||||
"login failed".to_string()
|
||||
)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -160,12 +160,12 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) {
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
// Internal UI events; still log for fidelity, but avoid heavy payloads.
|
||||
AppEvent::InsertHistoryCell(cell) => {
|
||||
AppEvent::InsertHistory(lines) => {
|
||||
let value = json!({
|
||||
"ts": now_ts(),
|
||||
"dir": "to_tui",
|
||||
"kind": "insert_history",
|
||||
"lines": cell.transcript_lines().len(),
|
||||
"lines": lines.len(),
|
||||
});
|
||||
LOGGER.write_json_line(value);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ pub(crate) fn shimmer_spans(text: &str) -> Vec<Span<'static>> {
|
||||
// Use time-based sweep synchronized to process start.
|
||||
let padding = 10usize;
|
||||
let period = chars.len() + padding * 2;
|
||||
let sweep_seconds = 2.0f32;
|
||||
let sweep_seconds = 2.5f32;
|
||||
let pos_f =
|
||||
(elapsed_since_start().as_secs_f32() % sweep_seconds) / sweep_seconds * (period as f32);
|
||||
let pos = pos_f as usize;
|
||||
|
||||
@@ -29,8 +29,6 @@ pub(crate) struct StatusIndicatorWidget {
|
||||
/// Latest text to display (truncated to the available width at render
|
||||
/// time).
|
||||
text: String,
|
||||
/// Animated header text (defaults to "Working").
|
||||
header: String,
|
||||
|
||||
/// Animation state: reveal target `text` progressively like a typewriter.
|
||||
/// We compute the currently visible prefix length based on the current
|
||||
@@ -47,7 +45,6 @@ impl StatusIndicatorWidget {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
|
||||
Self {
|
||||
text: String::from("waiting for model"),
|
||||
header: String::from("Working"),
|
||||
last_target_len: 0,
|
||||
base_frame: 0,
|
||||
reveal_len_at_base: 0,
|
||||
@@ -95,13 +92,6 @@ impl StatusIndicatorWidget {
|
||||
self.app_event_tx.send(AppEvent::CodexOp(Op::Interrupt));
|
||||
}
|
||||
|
||||
/// Update the animated header label (left of the brackets).
|
||||
pub(crate) fn update_header(&mut self, header: String) {
|
||||
if self.header != header {
|
||||
self.header = header;
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the animation and start revealing `text` from the beginning.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn restart_with_text(&mut self, text: String) {
|
||||
@@ -154,12 +144,12 @@ impl WidgetRef for StatusIndicatorWidget {
|
||||
|
||||
// Schedule next animation frame.
|
||||
self.app_event_tx
|
||||
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(32)));
|
||||
.send(AppEvent::ScheduleFrameIn(Duration::from_millis(100)));
|
||||
let idx = self.current_frame();
|
||||
let elapsed = self.start_time.elapsed().as_secs();
|
||||
let shown_now = self.current_shown_len(idx);
|
||||
let status_prefix: String = self.text.chars().take(shown_now).collect();
|
||||
let animated_spans = shimmer_spans(&self.header);
|
||||
let animated_spans = shimmer_spans("Working");
|
||||
|
||||
// Plain rendering: no borders or padding so the live cell is visually indistinguishable from terminal scrollback.
|
||||
let inner_width = area.width as usize;
|
||||
|
||||
@@ -2,6 +2,7 @@ use codex_core::config::Config;
|
||||
use ratatui::text::Line;
|
||||
|
||||
use super::HeaderEmitter;
|
||||
use super::StreamKind;
|
||||
use super::StreamState;
|
||||
|
||||
/// Sink for history insertions and animation control.
|
||||
@@ -17,7 +18,7 @@ pub(crate) struct AppEventHistorySink(pub(crate) crate::app_event_sender::AppEve
|
||||
impl HistorySink for AppEventHistorySink {
|
||||
fn insert_history(&self, lines: Vec<Line<'static>>) {
|
||||
self.0
|
||||
.send(crate::app_event::AppEvent::InsertHistoryLines(lines))
|
||||
.send(crate::app_event::AppEvent::InsertHistory(lines))
|
||||
}
|
||||
fn start_commit_animation(&self) {
|
||||
self.0
|
||||
@@ -35,8 +36,8 @@ type Lines = Vec<Line<'static>>;
|
||||
pub(crate) struct StreamController {
|
||||
config: Config,
|
||||
header: HeaderEmitter,
|
||||
state: StreamState,
|
||||
active: bool,
|
||||
states: [StreamState; 2],
|
||||
current_stream: Option<StreamKind>,
|
||||
finishing_after_drain: bool,
|
||||
}
|
||||
|
||||
@@ -45,8 +46,8 @@ impl StreamController {
|
||||
Self {
|
||||
config,
|
||||
header: HeaderEmitter::new(),
|
||||
state: StreamState::new(),
|
||||
active: false,
|
||||
states: [StreamState::new(), StreamState::new()],
|
||||
current_stream: None,
|
||||
finishing_after_drain: false,
|
||||
}
|
||||
}
|
||||
@@ -56,18 +57,29 @@ impl StreamController {
|
||||
}
|
||||
|
||||
pub(crate) fn is_write_cycle_active(&self) -> bool {
|
||||
self.active
|
||||
self.current_stream.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn clear_all(&mut self) {
|
||||
self.state.clear();
|
||||
self.active = false;
|
||||
self.states.iter_mut().for_each(|s| s.clear());
|
||||
self.current_stream = None;
|
||||
self.finishing_after_drain = false;
|
||||
// leave header state unchanged; caller decides when to reset
|
||||
}
|
||||
|
||||
fn emit_header_if_needed(&mut self, out_lines: &mut Lines) -> bool {
|
||||
self.header.maybe_emit(out_lines)
|
||||
#[inline]
|
||||
fn idx(kind: StreamKind) -> usize {
|
||||
kind as usize
|
||||
}
|
||||
fn state(&self, kind: StreamKind) -> &StreamState {
|
||||
&self.states[Self::idx(kind)]
|
||||
}
|
||||
fn state_mut(&mut self, kind: StreamKind) -> &mut StreamState {
|
||||
&mut self.states[Self::idx(kind)]
|
||||
}
|
||||
|
||||
fn emit_header_if_needed(&mut self, kind: StreamKind, out_lines: &mut Lines) -> bool {
|
||||
self.header.maybe_emit(kind, out_lines)
|
||||
}
|
||||
|
||||
#[inline]
|
||||
@@ -81,23 +93,56 @@ impl StreamController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Begin an answer stream. Does not emit header yet; it is emitted on first commit.
|
||||
pub(crate) fn begin(&mut self, _sink: &impl HistorySink) {
|
||||
// Starting a new stream cancels any pending finish-from-previous-stream animation.
|
||||
if !self.active {
|
||||
self.header.reset_for_stream();
|
||||
/// Begin a stream, flushing previously completed lines from any other
|
||||
/// active stream to maintain ordering.
|
||||
pub(crate) fn begin(&mut self, kind: StreamKind, sink: &impl HistorySink) {
|
||||
if let Some(current) = self.current_stream {
|
||||
if current != kind {
|
||||
// Synchronously flush completed lines from previous stream.
|
||||
let cfg = self.config.clone();
|
||||
let prev_state = self.state_mut(current);
|
||||
let newly_completed = prev_state.collector.commit_complete_lines(&cfg);
|
||||
if !newly_completed.is_empty() {
|
||||
prev_state.enqueue(newly_completed);
|
||||
}
|
||||
let step = prev_state.drain_all();
|
||||
if !step.history.is_empty() {
|
||||
let mut lines: Lines = Vec::new();
|
||||
self.emit_header_if_needed(current, &mut lines);
|
||||
lines.extend(step.history);
|
||||
// Ensure at most one trailing blank after the flushed block.
|
||||
Self::ensure_single_trailing_blank(&mut lines);
|
||||
sink.insert_history(lines);
|
||||
}
|
||||
self.current_stream = None;
|
||||
}
|
||||
}
|
||||
|
||||
if self.current_stream != Some(kind) {
|
||||
let prev = self.current_stream;
|
||||
self.current_stream = Some(kind);
|
||||
// Starting a new stream cancels any pending finish-from-previous-stream animation.
|
||||
self.finishing_after_drain = false;
|
||||
if prev.is_some() {
|
||||
self.header.reset_for_stream(kind);
|
||||
}
|
||||
// Emit header immediately for reasoning; for answers, defer to first commit.
|
||||
if matches!(kind, StreamKind::Reasoning) {
|
||||
let mut header_lines = Vec::new();
|
||||
if self.emit_header_if_needed(kind, &mut header_lines) {
|
||||
sink.insert_history(header_lines);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.finishing_after_drain = false;
|
||||
self.active = true;
|
||||
}
|
||||
|
||||
/// Push a delta; if it contains a newline, commit completed lines and start animation.
|
||||
pub(crate) fn push_and_maybe_commit(&mut self, delta: &str, sink: &impl HistorySink) {
|
||||
if !self.active {
|
||||
let Some(kind) = self.current_stream else {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let cfg = self.config.clone();
|
||||
let state = &mut self.state;
|
||||
let state = self.state_mut(kind);
|
||||
// Record that at least one delta was received for this stream
|
||||
if !delta.is_empty() {
|
||||
state.has_seen_delta = true;
|
||||
@@ -112,22 +157,42 @@ impl StreamController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Insert a reasoning section break and commit any newly completed lines.
|
||||
pub(crate) fn insert_reasoning_section_break(&mut self, sink: &impl HistorySink) {
|
||||
if self.current_stream != Some(StreamKind::Reasoning) {
|
||||
self.begin(StreamKind::Reasoning, sink);
|
||||
}
|
||||
let cfg = self.config.clone();
|
||||
let state = self.state_mut(StreamKind::Reasoning);
|
||||
state.collector.insert_section_break();
|
||||
let newly_completed = state.collector.commit_complete_lines(&cfg);
|
||||
if !newly_completed.is_empty() {
|
||||
state.enqueue(newly_completed);
|
||||
sink.start_commit_animation();
|
||||
}
|
||||
}
|
||||
|
||||
/// Finalize the active stream. If `flush_immediately` is true, drain and emit now.
|
||||
pub(crate) fn finalize(&mut self, flush_immediately: bool, sink: &impl HistorySink) -> bool {
|
||||
if !self.active {
|
||||
pub(crate) fn finalize(
|
||||
&mut self,
|
||||
kind: StreamKind,
|
||||
flush_immediately: bool,
|
||||
sink: &impl HistorySink,
|
||||
) -> bool {
|
||||
if self.current_stream != Some(kind) {
|
||||
return false;
|
||||
}
|
||||
let cfg = self.config.clone();
|
||||
// Finalize collector first.
|
||||
let remaining = {
|
||||
let state = &mut self.state;
|
||||
let state = self.state_mut(kind);
|
||||
state.collector.finalize_and_drain(&cfg)
|
||||
};
|
||||
if flush_immediately {
|
||||
// Collect all output first to avoid emitting headers when there is no content.
|
||||
let mut out_lines: Lines = Vec::new();
|
||||
{
|
||||
let state = &mut self.state;
|
||||
let state = self.state_mut(kind);
|
||||
if !remaining.is_empty() {
|
||||
state.enqueue(remaining);
|
||||
}
|
||||
@@ -136,28 +201,28 @@ impl StreamController {
|
||||
}
|
||||
if !out_lines.is_empty() {
|
||||
let mut lines_with_header: Lines = Vec::new();
|
||||
self.emit_header_if_needed(&mut lines_with_header);
|
||||
self.emit_header_if_needed(kind, &mut lines_with_header);
|
||||
lines_with_header.extend(out_lines);
|
||||
Self::ensure_single_trailing_blank(&mut lines_with_header);
|
||||
sink.insert_history(lines_with_header);
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
self.state.clear();
|
||||
// Allow a subsequent block in this turn to emit its header.
|
||||
self.header.allow_reemit_in_turn();
|
||||
self.state_mut(kind).clear();
|
||||
// Allow a subsequent block of the same kind in this turn to emit its header.
|
||||
self.header.allow_reemit_for_same_kind_in_turn(kind);
|
||||
// Also clear the per-stream emitted flag so the header can render again.
|
||||
self.header.reset_for_stream();
|
||||
self.active = false;
|
||||
self.header.reset_for_stream(kind);
|
||||
self.current_stream = None;
|
||||
self.finishing_after_drain = false;
|
||||
true
|
||||
} else {
|
||||
if !remaining.is_empty() {
|
||||
let state = &mut self.state;
|
||||
let state = self.state_mut(kind);
|
||||
state.enqueue(remaining);
|
||||
}
|
||||
// Spacer animated out
|
||||
self.state.enqueue(vec![Line::from("")]);
|
||||
self.state_mut(kind).enqueue(vec![Line::from("")]);
|
||||
self.finishing_after_drain = true;
|
||||
sink.start_commit_animation();
|
||||
false
|
||||
@@ -166,29 +231,32 @@ impl StreamController {
|
||||
|
||||
/// Step animation: commit at most one queued line and handle end-of-drain cleanup.
|
||||
pub(crate) fn on_commit_tick(&mut self, sink: &impl HistorySink) -> bool {
|
||||
if !self.active {
|
||||
let Some(kind) = self.current_stream else {
|
||||
return false;
|
||||
}
|
||||
let step = { self.state.step() };
|
||||
};
|
||||
let step = {
|
||||
let state = self.state_mut(kind);
|
||||
state.step()
|
||||
};
|
||||
if !step.history.is_empty() {
|
||||
let mut lines: Lines = Vec::new();
|
||||
self.emit_header_if_needed(&mut lines);
|
||||
self.emit_header_if_needed(kind, &mut lines);
|
||||
let mut out = lines;
|
||||
out.extend(step.history);
|
||||
sink.insert_history(out);
|
||||
}
|
||||
|
||||
let is_idle = self.state.is_idle();
|
||||
let is_idle = self.state(kind).is_idle();
|
||||
if is_idle {
|
||||
sink.stop_commit_animation();
|
||||
if self.finishing_after_drain {
|
||||
// Reset and notify
|
||||
self.state.clear();
|
||||
// Allow a subsequent block in this turn to emit its header.
|
||||
self.header.allow_reemit_in_turn();
|
||||
self.state_mut(kind).clear();
|
||||
// Allow a subsequent block of the same kind in this turn to emit its header.
|
||||
self.header.allow_reemit_for_same_kind_in_turn(kind);
|
||||
// Also clear the per-stream emitted flag so the header can render again.
|
||||
self.header.reset_for_stream();
|
||||
self.active = false;
|
||||
self.header.reset_for_stream(kind);
|
||||
self.current_stream = None;
|
||||
self.finishing_after_drain = false;
|
||||
return true;
|
||||
}
|
||||
@@ -199,14 +267,24 @@ impl StreamController {
|
||||
/// Apply a full final answer: replace queued content with only the remaining tail,
|
||||
/// then finalize immediately and notify completion.
|
||||
pub(crate) fn apply_final_answer(&mut self, message: &str, sink: &impl HistorySink) -> bool {
|
||||
self.apply_full_final(message, sink)
|
||||
self.apply_full_final(StreamKind::Answer, message, true, sink)
|
||||
}
|
||||
|
||||
fn apply_full_final(&mut self, message: &str, sink: &impl HistorySink) -> bool {
|
||||
self.begin(sink);
|
||||
pub(crate) fn apply_final_reasoning(&mut self, message: &str, sink: &impl HistorySink) -> bool {
|
||||
self.apply_full_final(StreamKind::Reasoning, message, false, sink)
|
||||
}
|
||||
|
||||
fn apply_full_final(
|
||||
&mut self,
|
||||
kind: StreamKind,
|
||||
message: &str,
|
||||
immediate: bool,
|
||||
sink: &impl HistorySink,
|
||||
) -> bool {
|
||||
self.begin(kind, sink);
|
||||
|
||||
{
|
||||
let state = &mut self.state;
|
||||
let state = self.state_mut(kind);
|
||||
// Only inject the final full message if we have not seen any deltas for this stream.
|
||||
// If deltas were received, rely on the collector's existing buffer to avoid duplication.
|
||||
if !state.has_seen_delta && !message.is_empty() {
|
||||
@@ -223,6 +301,7 @@ impl StreamController {
|
||||
.replace_with_and_mark_committed(&msg, committed);
|
||||
}
|
||||
}
|
||||
self.finalize(true, sink)
|
||||
|
||||
self.finalize(kind, immediate, sink)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,12 @@ use crate::markdown_stream::AnimatedLineStreamer;
|
||||
use crate::markdown_stream::MarkdownStreamCollector;
|
||||
pub(crate) mod controller;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum StreamKind {
|
||||
Answer,
|
||||
Reasoning,
|
||||
}
|
||||
|
||||
pub(crate) struct StreamState {
|
||||
pub(crate) collector: MarkdownStreamCollector,
|
||||
pub(crate) streamer: AnimatedLineStreamer,
|
||||
@@ -36,44 +42,92 @@ impl StreamState {
|
||||
}
|
||||
|
||||
pub(crate) struct HeaderEmitter {
|
||||
emitted_this_turn: bool,
|
||||
emitted_in_stream: bool,
|
||||
reasoning_emitted_this_turn: bool,
|
||||
answer_emitted_this_turn: bool,
|
||||
reasoning_emitted_in_stream: bool,
|
||||
answer_emitted_in_stream: bool,
|
||||
}
|
||||
|
||||
impl HeaderEmitter {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
emitted_this_turn: false,
|
||||
emitted_in_stream: false,
|
||||
reasoning_emitted_this_turn: false,
|
||||
answer_emitted_this_turn: false,
|
||||
reasoning_emitted_in_stream: false,
|
||||
answer_emitted_in_stream: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn reset_for_new_turn(&mut self) {
|
||||
self.emitted_this_turn = false;
|
||||
self.emitted_in_stream = false;
|
||||
self.reasoning_emitted_this_turn = false;
|
||||
self.answer_emitted_this_turn = false;
|
||||
self.reasoning_emitted_in_stream = false;
|
||||
self.answer_emitted_in_stream = false;
|
||||
}
|
||||
|
||||
pub(crate) fn reset_for_stream(&mut self) {
|
||||
self.emitted_in_stream = false;
|
||||
}
|
||||
|
||||
/// Allow emitting the header again within the current turn after a finalize.
|
||||
pub(crate) fn allow_reemit_in_turn(&mut self) {
|
||||
self.emitted_this_turn = false;
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_emit(&mut self, out_lines: &mut Vec<ratatui::text::Line<'static>>) -> bool {
|
||||
if !self.emitted_in_stream && !self.emitted_this_turn {
|
||||
out_lines.push(render_header_line());
|
||||
self.emitted_in_stream = true;
|
||||
self.emitted_this_turn = true;
|
||||
return true;
|
||||
pub(crate) fn reset_for_stream(&mut self, kind: StreamKind) {
|
||||
match kind {
|
||||
StreamKind::Reasoning => self.reasoning_emitted_in_stream = false,
|
||||
StreamKind::Answer => self.answer_emitted_in_stream = false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn has_emitted_for_stream(&self, kind: StreamKind) -> bool {
|
||||
match kind {
|
||||
StreamKind::Reasoning => self.reasoning_emitted_in_stream,
|
||||
StreamKind::Answer => self.answer_emitted_in_stream,
|
||||
}
|
||||
}
|
||||
|
||||
/// Allow emitting the header again for the same kind within the current turn.
|
||||
///
|
||||
/// This is used when a stream (e.g., Answer) is finalized and a subsequent
|
||||
/// block of the same kind is started within the same turn. Without this,
|
||||
/// only the first block would render a header.
|
||||
pub(crate) fn allow_reemit_for_same_kind_in_turn(&mut self, kind: StreamKind) {
|
||||
match kind {
|
||||
StreamKind::Reasoning => self.reasoning_emitted_this_turn = false,
|
||||
StreamKind::Answer => self.answer_emitted_this_turn = false,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn maybe_emit(
|
||||
&mut self,
|
||||
kind: StreamKind,
|
||||
out_lines: &mut Vec<ratatui::text::Line<'static>>,
|
||||
) -> bool {
|
||||
let already_emitted_this_turn = match kind {
|
||||
StreamKind::Reasoning => self.reasoning_emitted_this_turn,
|
||||
StreamKind::Answer => self.answer_emitted_this_turn,
|
||||
};
|
||||
let already_emitted_in_stream = self.has_emitted_for_stream(kind);
|
||||
if !already_emitted_in_stream && !already_emitted_this_turn {
|
||||
out_lines.push(render_header_line(kind));
|
||||
match kind {
|
||||
StreamKind::Reasoning => {
|
||||
self.reasoning_emitted_in_stream = true;
|
||||
self.reasoning_emitted_this_turn = true;
|
||||
// Reset opposite header so it may be emitted again this turn
|
||||
self.answer_emitted_this_turn = false;
|
||||
}
|
||||
StreamKind::Answer => {
|
||||
self.answer_emitted_in_stream = true;
|
||||
self.answer_emitted_this_turn = true;
|
||||
// Reset opposite header so it may be emitted again this turn
|
||||
self.reasoning_emitted_this_turn = false;
|
||||
}
|
||||
}
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn render_header_line() -> ratatui::text::Line<'static> {
|
||||
fn render_header_line(kind: StreamKind) -> ratatui::text::Line<'static> {
|
||||
use ratatui::style::Stylize;
|
||||
ratatui::text::Line::from("codex".magenta().bold())
|
||||
match kind {
|
||||
StreamKind::Reasoning => ratatui::text::Line::from("thinking".magenta().italic()),
|
||||
StreamKind::Answer => ratatui::text::Line::from("codex".magenta().bold()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,23 @@ pub type Tui = Terminal<CrosstermBackend<Stdout>>;
|
||||
|
||||
/// Initialize the terminal (inline viewport; history stays in normal scrollback)
|
||||
pub fn init(_config: &Config) -> Result<Tui> {
|
||||
set_modes()?;
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
|
||||
enable_raw_mode()?;
|
||||
// Enable keyboard enhancement flags so modifiers for keys like Enter are disambiguated.
|
||||
// chat_composer.rs is using a keyboard event listener to enter for any modified keys
|
||||
// to create a new line that require this.
|
||||
// Some terminals (notably legacy Windows consoles) do not support
|
||||
// keyboard enhancement flags. Attempt to enable them, but continue
|
||||
// gracefully if unsupported.
|
||||
let _ = execute!(
|
||||
stdout(),
|
||||
PushKeyboardEnhancementFlags(
|
||||
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
||||
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
||||
)
|
||||
);
|
||||
set_panic_hook();
|
||||
|
||||
// Clear screen and move cursor to top-left before drawing UI
|
||||
@@ -37,27 +53,13 @@ pub fn init(_config: &Config) -> Result<Tui> {
|
||||
fn set_panic_hook() {
|
||||
let hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic_info| {
|
||||
let _ = restore_modes(); // ignore any errors as we are already failing
|
||||
let _ = restore(); // ignore any errors as we are already failing
|
||||
hook(panic_info);
|
||||
}));
|
||||
}
|
||||
|
||||
pub fn set_modes() -> Result<()> {
|
||||
execute!(stdout(), EnableBracketedPaste)?;
|
||||
enable_raw_mode()?;
|
||||
let _ = execute!(
|
||||
stdout(),
|
||||
PushKeyboardEnhancementFlags(
|
||||
KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES
|
||||
| KeyboardEnhancementFlags::REPORT_EVENT_TYPES
|
||||
| KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS
|
||||
)
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Restore the terminal to its original state
|
||||
pub fn restore_modes() -> Result<()> {
|
||||
pub fn restore() -> Result<()> {
|
||||
// Pop may fail on platforms that didn't support the push; ignore errors.
|
||||
let _ = execute!(stdout(), PopKeyboardEnhancementFlags);
|
||||
execute!(stdout(), DisableBracketedPaste)?;
|
||||
|
||||
@@ -29,7 +29,6 @@ use ratatui::widgets::Wrap;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::history_cell;
|
||||
|
||||
/// Request coming from the agent that needs user approval.
|
||||
pub(crate) enum ApprovalRequest {
|
||||
@@ -110,34 +109,6 @@ pub(crate) struct UserApprovalWidget<'a> {
|
||||
done: bool,
|
||||
}
|
||||
|
||||
pub fn to_command_display<'a>(
|
||||
first_line: Vec<Span<'a>>,
|
||||
cmd: String,
|
||||
last_line: Vec<Span<'a>>,
|
||||
) -> Vec<Line<'a>> {
|
||||
let command_lines: Vec<Span> = cmd
|
||||
.lines()
|
||||
.map(|line| Span::from(line.to_string()).style(Style::new().add_modifier(Modifier::DIM)))
|
||||
.collect();
|
||||
|
||||
let mut lines: Vec<Line<'a>> = vec![];
|
||||
|
||||
let mut first_line = first_line.clone();
|
||||
if command_lines.len() == 1 {
|
||||
first_line.push(command_lines[0].clone());
|
||||
first_line.extend(last_line);
|
||||
} else {
|
||||
for line in command_lines {
|
||||
lines.push(Line::from(vec![Span::from(" "), line]));
|
||||
}
|
||||
let last_line = last_line.clone();
|
||||
lines.push(Line::from(last_line));
|
||||
}
|
||||
lines.insert(0, Line::from(first_line));
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
impl UserApprovalWidget<'_> {
|
||||
pub(crate) fn new(approval_request: ApprovalRequest, app_event_tx: AppEventSender) -> Self {
|
||||
let confirmation_prompt = match &approval_request {
|
||||
@@ -145,13 +116,17 @@ impl UserApprovalWidget<'_> {
|
||||
command, reason, ..
|
||||
} => {
|
||||
let cmd = strip_bash_lc_and_escape(command);
|
||||
let mut contents: Vec<Line> = to_command_display(
|
||||
vec!["? ".fg(Color::Blue), "Codex wants to run ".bold()],
|
||||
cmd,
|
||||
vec![],
|
||||
);
|
||||
|
||||
contents.push(Line::from(""));
|
||||
// Present a single-line summary without cwd: "codex wants to run: <cmd>"
|
||||
let mut cmd_span: Span = cmd.clone().into();
|
||||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
||||
let mut contents: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
"? ".fg(Color::Blue),
|
||||
"Codex wants to run ".bold(),
|
||||
cmd_span,
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
if let Some(reason) = reason {
|
||||
contents.push(Line::from(reason.clone().italic()));
|
||||
contents.push(Line::from(""));
|
||||
@@ -259,9 +234,69 @@ impl UserApprovalWidget<'_> {
|
||||
}
|
||||
|
||||
fn send_decision_with_feedback(&mut self, decision: ReviewDecision, feedback: String) {
|
||||
self.app_event_tx.send(AppEvent::InsertHistoryCell(Box::new(
|
||||
history_cell::new_exec_approval_decision(&self.approval_request, decision, feedback),
|
||||
)));
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
match &self.approval_request {
|
||||
ApprovalRequest::Exec { command, .. } => {
|
||||
let cmd = strip_bash_lc_and_escape(command);
|
||||
let mut cmd_span: Span = cmd.clone().into();
|
||||
cmd_span.style = cmd_span.style.add_modifier(Modifier::DIM);
|
||||
|
||||
// Result line based on decision.
|
||||
match decision {
|
||||
ReviewDecision::Approved => {
|
||||
lines.push(Line::from(vec![
|
||||
"✔ ".fg(Color::Green),
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
cmd_span,
|
||||
" ".into(),
|
||||
"this time".bold(),
|
||||
]));
|
||||
}
|
||||
ReviewDecision::ApprovedForSession => {
|
||||
lines.push(Line::from(vec![
|
||||
"✔ ".fg(Color::Green),
|
||||
"You ".into(),
|
||||
"approved".bold(),
|
||||
" codex to run ".into(),
|
||||
cmd_span,
|
||||
" ".into(),
|
||||
"every time this session".bold(),
|
||||
]));
|
||||
}
|
||||
ReviewDecision::Denied => {
|
||||
lines.push(Line::from(vec![
|
||||
"✗ ".fg(Color::Red),
|
||||
"You ".into(),
|
||||
"did not approve".bold(),
|
||||
" codex to run ".into(),
|
||||
cmd_span,
|
||||
]));
|
||||
}
|
||||
ReviewDecision::Abort => {
|
||||
lines.push(Line::from(vec![
|
||||
"✗ ".fg(Color::Red),
|
||||
"You ".into(),
|
||||
"canceled".bold(),
|
||||
" the request to run ".into(),
|
||||
cmd_span,
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
ApprovalRequest::ApplyPatch { .. } => {
|
||||
lines.push(Line::from(format!("patch approval decision: {decision:?}")));
|
||||
}
|
||||
}
|
||||
if !feedback.trim().is_empty() {
|
||||
lines.push(Line::from("feedback:"));
|
||||
for l in feedback.lines() {
|
||||
lines.push(Line::from(l.to_string()));
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
self.app_event_tx.send(AppEvent::InsertHistory(lines));
|
||||
|
||||
let op = match &self.approval_request {
|
||||
ApprovalRequest::Exec { id, .. } => Op::ExecApproval {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
To get started, describe a task or try one of these commands:
|
||||
thinking
|
||||
Investigating binary sizes
|
||||
|
||||
/init - create an AGENTS.md file with instructions for Codex
|
||||
/status - show current session configuration and token usage
|
||||
/diff - show git diff (including untracked files)
|
||||
/prompts - show example prompts
|
||||
I need to check the codex-rs repository to explain why the project's binaries
|
||||
are large. The user is likely seeking specifics about the setup: are Rust builds
|
||||
static, what features are enabled, and is debug information included? It could
|
||||
be due to static linking, included OpenSSL, or how panic handling is set up. I
|
||||
should look into the Cargo.toml file to confirm features and profiles without
|
||||
needing to edit any code. Let's get started on this!
|
||||
|
||||
codex
|
||||
I’m going to scan the workspace and Cargo manifests to see build profiles and
|
||||
@@ -34,6 +37,16 @@ dependencies that impact binary size. Then I’ll summarize the main causes.
|
||||
# ratatui = { path = "../../ratatui" }
|
||||
ratatui = { git = "https://github.com/nornagon/ratatui", branch =
|
||||
"nornagon-v0.29.0-patch" }
|
||||
Optimizing Rust Release Profile
|
||||
|
||||
I'm reviewing the workspace's release profile, which has settings like lto=fat,
|
||||
strip=symbols, and codegen-units=1 to reduce binary size. However, it seems that
|
||||
"strip = symbols" only removes symbols, not debug information. In Rust 1.73+,
|
||||
both "symbols" and "debuginfo" stripping options are available. I should check
|
||||
whether using these results in a smaller binary. I’ll also skim through crates
|
||||
for heavy dependencies in Cargo.toml, including cli, core, exec, linux-sandbox,
|
||||
tui, login, ollama, and mcp.
|
||||
|
||||
⚡ Running for d in ansi-escape apply-patch arg0 cli common core exec execpolicy
|
||||
file-search linux-sandbox login mcp-client mcp-server mcp-types ollama tui; do
|
||||
echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
|
||||
@@ -168,6 +181,85 @@ echo "--- $d/Cargo.toml"; sed -n '1,200p' $d/Cargo.toml; echo; done
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
thinking
|
||||
Considering binary size impacts
|
||||
|
||||
I’m evaluating how binary size is influenced by various factors. There are many
|
||||
binaries, such as codex and codex-exec, which include dependencies by default,
|
||||
pulling in features like Tokio and hyper. For instance, reqwest includes
|
||||
default features like TLS through rustls. If default features remain enabled,
|
||||
it inflates the size with many additional components. I notice that for musl
|
||||
targets, using vendored builds of OpenSSL could significantly increase binary
|
||||
size. This is quite the intricate web of dependencies!
|
||||
|
||||
Analyzing musl and binary size
|
||||
|
||||
I’m considering the implications of building for musl with static linking.
|
||||
In this case, OpenSSL might come into play through some crates, but reqwest
|
||||
defaults to using rustls, although openssl-sys vendored could increase binary
|
||||
size. It’s interesting to think about whether they’re targeting musl, especially
|
||||
since it seems they might often be building for Mac and Linux with dynamic
|
||||
linking instead.
|
||||
|
||||
I also realize there’s a difference between debug and release builds — release
|
||||
should be smaller due to optimizations, but if they’re defaulting to debug
|
||||
builds, that could lead to much larger binaries.
|
||||
|
||||
Assessing crate impacts on size
|
||||
|
||||
I need to be precise when analyzing binary sizes from various crates. Some
|
||||
crates, like ratatui-image, include heavy features such as libjpeg decoders,
|
||||
which can really bulk things up. Then there are the tree-sitter crates that
|
||||
contain large static grammar tables, adding significant size. The starlark crate
|
||||
and tracing-subscriber also contribute to size increases.
|
||||
|
||||
With static linking per binary being standard in Rust, it leads to multiple
|
||||
copies of the same dependencies unless cdylibs are used. That makes the compile
|
||||
times longer, but using LTO helps reduce bloat somewhat through deduplication.
|
||||
|
||||
Evaluating binary size factors
|
||||
|
||||
Having multiple binaries indeed increases the overall size significantly.
|
||||
I'm considering options like upgrading to "opt-level = 'z'" or using
|
||||
"strip=debuginfo" for reducing size. It's interesting that while the profile
|
||||
only sets symbols for stripping, not debuginfo, this could inflate sizes if
|
||||
retained in release builds.
|
||||
|
||||
The distinction between "symbols" and "debuginfo" is critical; "symbols" keeps
|
||||
debug information which may not align with the goal of minimizing size. I’d like
|
||||
to verify with the Rust documentation, but my network access is restricted.
|
||||
|
||||
Explaining large binary sizes
|
||||
|
||||
I’m analyzing why binaries in this project are so large. The primary reasons
|
||||
include monolithic static linking, where each binary includes the full
|
||||
dependency graph without shared libraries. Heavy dependencies such as reqwest,
|
||||
hyper, and TLS components also contribute significantly to size, along with
|
||||
image decoders and other libraries.
|
||||
|
||||
Using MUSL targets means vendored OpenSSL can drastically increase the size due
|
||||
to static linking. Furthermore, if the release profile keeps debug information
|
||||
due to using "strip = symbols," that can lead to larger binaries as well. It’s a
|
||||
mix of several factors, including panic strategies and platform-specific linking
|
||||
behavior.
|
||||
|
||||
Identifying binary size contributors
|
||||
|
||||
I’m considering why binaries are still large even without static linking. The
|
||||
tracing-subscriber crate potentially adds weight because it includes regex,
|
||||
though it can sometimes use a lighter version. References from files like "core/
|
||||
Cargo.toml" show dependencies like reqwest and tokio, while the tui uses the
|
||||
image crate.
|
||||
|
||||
To present this clearly, I’ll format the final answer with two main sections:
|
||||
"Main Causes" and "Build-Mode Notes." I can also include brief suggestions for
|
||||
reducing size, but I want to stay focused on answering the user's question.
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
codex
|
||||
Here’s what’s driving size in this workspace’s binaries.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user