Compare commits

..

3 Commits

Author SHA1 Message Date
easong-openai
141895b03b Merge branch 'pakrym/display-session-id-in-status' of github.com:openai/codex into pakrym/display-session-id-in-status 2025-08-14 18:56:36 -07:00
pakrym-oai
b39713424d Display session id in status 2025-08-14 18:42:24 -07:00
pakrym-oai
89fda7a776 Display session id in status 2025-08-14 07:47:01 -07:00
28 changed files with 825 additions and 772 deletions

1
codex-rs/Cargo.lock generated
View File

@@ -924,7 +924,6 @@ dependencies = [
"strum 0.27.2",
"strum_macros 0.27.2",
"supports-color",
"tempfile",
"textwrap 0.16.2",
"tokio",
"tracing",

View File

@@ -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);

View File

@@ -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()),

View File

@@ -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) = &notify_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(

View File

@@ -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!(

View File

@@ -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"] }

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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;

View File

@@ -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.

View File

@@ -2,5 +2,8 @@
source: tui/src/chatwidget/tests.rs
expression: combined
---
thinking
I will first analyze the request.
codex
Here is the result.

View File

@@ -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"
);
}

View File

@@ -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::*;

View File

@@ -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>);

View File

@@ -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}"
);

View File

@@ -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.

View File

@@ -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));
}
})
});
}
}

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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)
}
}

View File

@@ -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()),
}
}

View File

@@ -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)?;

View File

@@ -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 {

View File

@@ -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
Im going to scan the workspace and Cargo manifests to see build profiles and
@@ -34,6 +37,16 @@ dependencies that impact binary size. Then Ill 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. Ill 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
Im 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
Im 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. Its interesting to think about whether theyre targeting musl, especially
since it seems they might often be building for Mac and Linux with dynamic
linking instead.
I also realize theres a difference between debug and release builds — release
should be smaller due to optimizations, but if theyre 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. Id like
to verify with the Rust documentation, but my network access is restricted.
Explaining large binary sizes
Im 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. Its a
mix of several factors, including panic strategies and platform-specific linking
behavior.
Identifying binary size contributors
Im 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, Ill 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
Heres whats driving size in this workspaces binaries.