mirror of
https://github.com/openai/codex.git
synced 2026-05-16 09:12:54 +00:00
Compare commits
9 Commits
dev/david.
...
codex/add-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bfbaf2b83 | ||
|
|
a5e17cda6b | ||
|
|
8a980399c5 | ||
|
|
af8c1cdf12 | ||
|
|
57c973b571 | ||
|
|
2d5de795aa | ||
|
|
f25b2e8e2c | ||
|
|
a575effbb0 | ||
|
|
6cef86f05b |
@@ -42,6 +42,15 @@ impl From<std::io::Error> for ApplyPatchError {
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&std::io::Error> for ApplyPatchError {
|
||||
fn from(err: &std::io::Error) -> Self {
|
||||
ApplyPatchError::IoError(IoError {
|
||||
context: "I/O error".to_string(),
|
||||
source: std::io::Error::new(err.kind(), err.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("{context}: {source}")]
|
||||
pub struct IoError {
|
||||
@@ -366,13 +375,21 @@ pub fn apply_hunks(
|
||||
match apply_hunks_to_files(hunks) {
|
||||
Ok(affected) => {
|
||||
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
|
||||
Ok(())
|
||||
}
|
||||
Err(err) => {
|
||||
writeln!(stderr, "{err:?}").map_err(ApplyPatchError::from)?;
|
||||
let msg = err.to_string();
|
||||
writeln!(stderr, "{msg}").map_err(ApplyPatchError::from)?;
|
||||
if let Some(io) = err.downcast_ref::<std::io::Error>() {
|
||||
Err(ApplyPatchError::from(io))
|
||||
} else {
|
||||
Err(ApplyPatchError::IoError(IoError {
|
||||
context: msg,
|
||||
source: std::io::Error::other(err),
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Applies each parsed patch hunk to the filesystem.
|
||||
@@ -1238,4 +1255,24 @@ g
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_apply_patch_fails_on_write_error() {
|
||||
let dir = tempdir().unwrap();
|
||||
let path = dir.path().join("readonly.txt");
|
||||
fs::write(&path, "before\n").unwrap();
|
||||
let mut perms = fs::metadata(&path).unwrap().permissions();
|
||||
perms.set_readonly(true);
|
||||
fs::set_permissions(&path, perms).unwrap();
|
||||
|
||||
let patch = wrap_patch(&format!(
|
||||
"*** Update File: {}\n@@\n-before\n+after\n*** End Patch",
|
||||
path.display()
|
||||
));
|
||||
|
||||
let mut stdout = Vec::new();
|
||||
let mut stderr = Vec::new();
|
||||
let result = apply_patch(&patch, &mut stdout, &mut stderr);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +121,9 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
|
||||
let mut tui_cli = cli.interactive;
|
||||
prepend_config_flags(&mut tui_cli.config_overrides, cli.config_overrides);
|
||||
let usage = codex_tui::run_main(tui_cli, codex_linux_sandbox_exe).await?;
|
||||
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||
if !usage.is_zero() {
|
||||
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||
}
|
||||
}
|
||||
Some(Subcommand::Exec(mut exec_cli)) => {
|
||||
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);
|
||||
|
||||
@@ -127,6 +127,15 @@ impl ModelClient {
|
||||
|
||||
let auth_mode = auth.as_ref().map(|a| a.mode);
|
||||
|
||||
if self.config.model_family.family == "2025-08-06-model"
|
||||
&& auth_mode != Some(AuthMode::ChatGPT)
|
||||
{
|
||||
return Err(CodexErr::UnexpectedStatus(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"2025-08-06-model is only supported with ChatGPT auth, run `codex login status` to check your auth status and `codex login` to login with ChatGPT".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let store = prompt.store && auth_mode != Some(AuthMode::ChatGPT);
|
||||
|
||||
let full_instructions = prompt.get_full_instructions(&self.config.model_family);
|
||||
|
||||
@@ -89,6 +89,11 @@ pub fn find_family_for_model(slug: &str) -> Option<ModelFamily> {
|
||||
simple_model_family!(slug, "gpt-oss")
|
||||
} else if slug.starts_with("gpt-3.5") {
|
||||
simple_model_family!(slug, "gpt-3.5")
|
||||
} else if slug.starts_with("2025-08-06-model") {
|
||||
model_family!(
|
||||
slug, "2025-08-06-model",
|
||||
supports_reasoning_summaries: true,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -77,6 +77,11 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option<ModelInfo> {
|
||||
max_output_tokens: 4_096,
|
||||
}),
|
||||
|
||||
"2025-08-06-model" => Some(ModelInfo {
|
||||
context_window: 200_000,
|
||||
max_output_tokens: 100_000,
|
||||
}),
|
||||
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -429,6 +429,12 @@ pub struct TokenUsage {
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
impl TokenUsage {
|
||||
pub fn is_zero(&self) -> bool {
|
||||
self.total_tokens == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
pub struct FinalOutput {
|
||||
pub token_usage: TokenUsage,
|
||||
|
||||
@@ -4,6 +4,7 @@ use chrono::Utc;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::io::Write;
|
||||
@@ -11,6 +12,7 @@ use std::io::Write;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Child;
|
||||
use std::process::Stdio;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
@@ -183,6 +185,59 @@ fn get_auth_file(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join("auth.json")
|
||||
}
|
||||
|
||||
/// Represents a running login subprocess. The child can be killed by holding
|
||||
/// the mutex and calling `kill()`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpawnedLogin {
|
||||
pub child: Arc<Mutex<Child>>,
|
||||
pub stdout: Arc<Mutex<Vec<u8>>>,
|
||||
pub stderr: Arc<Mutex<Vec<u8>>>,
|
||||
}
|
||||
|
||||
/// Spawn the ChatGPT login Python server as a child process and return a handle to its process.
|
||||
pub fn spawn_login_with_chatgpt(codex_home: &Path) -> std::io::Result<SpawnedLogin> {
|
||||
let mut cmd = std::process::Command::new("python3");
|
||||
cmd.arg("-c")
|
||||
.arg(SOURCE_FOR_PYTHON_SERVER)
|
||||
.env("CODEX_HOME", codex_home)
|
||||
.env("CODEX_CLIENT_ID", CLIENT_ID)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
let stdout_buf = Arc::new(Mutex::new(Vec::new()));
|
||||
let stderr_buf = Arc::new(Mutex::new(Vec::new()));
|
||||
|
||||
if let Some(mut out) = child.stdout.take() {
|
||||
let buf = stdout_buf.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut tmp = Vec::new();
|
||||
let _ = std::io::copy(&mut out, &mut tmp);
|
||||
if let Ok(mut b) = buf.lock() {
|
||||
b.extend_from_slice(&tmp);
|
||||
}
|
||||
});
|
||||
}
|
||||
if let Some(mut err) = child.stderr.take() {
|
||||
let buf = stderr_buf.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut tmp = Vec::new();
|
||||
let _ = std::io::copy(&mut err, &mut tmp);
|
||||
if let Ok(mut b) = buf.lock() {
|
||||
b.extend_from_slice(&tmp);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(SpawnedLogin {
|
||||
child: Arc::new(Mutex::new(child)),
|
||||
stdout: stdout_buf,
|
||||
stderr: stderr_buf,
|
||||
})
|
||||
}
|
||||
|
||||
/// Run `python3 -c {{SOURCE_FOR_PYTHON_SERVER}}` with the CODEX_HOME
|
||||
/// environment variable set to the provided `codex_home` path. If the
|
||||
/// subprocess exits 0, read the OPENAI_API_KEY property out of
|
||||
@@ -234,7 +289,7 @@ pub fn login_with_api_key(codex_home: &Path, api_key: &str) -> std::io::Result<(
|
||||
/// Attempt to read and refresh the `auth.json` file in the given `CODEX_HOME` directory.
|
||||
/// Returns the full AuthDotJson structure after refreshing if necessary.
|
||||
pub fn try_read_auth_json(auth_file: &Path) -> std::io::Result<AuthDotJson> {
|
||||
let mut file = std::fs::File::open(auth_file)?;
|
||||
let mut file = File::open(auth_file)?;
|
||||
let mut contents = String::new();
|
||||
file.read_to_string(&mut contents)?;
|
||||
let auth_dot_json: AuthDotJson = serde_json::from_str(&contents)?;
|
||||
|
||||
@@ -110,7 +110,7 @@ def main() -> None:
|
||||
eprint(f"Failed to open browser: {e}")
|
||||
|
||||
eprint(
|
||||
f"If your browser did not open, navigate to this URL to authenticate:\n\n{auth_url}"
|
||||
f". If your browser did not open, navigate to this URL to authenticate: \n\n{auth_url}"
|
||||
)
|
||||
|
||||
# Run the server in the main thread until `shutdown()` is called by the
|
||||
|
||||
@@ -5,6 +5,10 @@ use crate::file_search::FileSearchManager;
|
||||
use crate::get_git_diff::get_git_diff;
|
||||
use crate::git_warning_screen::GitWarningOutcome;
|
||||
use crate::git_warning_screen::GitWarningScreen;
|
||||
use crate::onboarding::onboarding_screen::KeyEventResult;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::onboarding::onboarding_screen::OnboardingScreen;
|
||||
use crate::should_show_login_screen;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::tui;
|
||||
use codex_core::config::Config;
|
||||
@@ -35,6 +39,9 @@ const REDRAW_DEBOUNCE: Duration = Duration::from_millis(10);
|
||||
/// Top-level application state: which full-screen view is currently active.
|
||||
#[allow(clippy::large_enum_variant)]
|
||||
enum AppState<'a> {
|
||||
Onboarding {
|
||||
screen: OnboardingScreen,
|
||||
},
|
||||
/// The main chat UI is visible.
|
||||
Chat {
|
||||
/// Boxed to avoid a large enum variant and reduce the overall size of
|
||||
@@ -42,7 +49,9 @@ enum AppState<'a> {
|
||||
widget: Box<ChatWidget<'a>>,
|
||||
},
|
||||
/// The start-up warning that recommends running codex inside a Git repo.
|
||||
GitWarning { screen: GitWarningScreen },
|
||||
GitWarning {
|
||||
screen: GitWarningScreen,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct App<'a> {
|
||||
@@ -133,7 +142,20 @@ impl App<'_> {
|
||||
});
|
||||
}
|
||||
|
||||
let (app_state, chat_args) = if show_git_warning {
|
||||
let show_login_screen = should_show_login_screen(&config);
|
||||
let (app_state, chat_args) = if show_login_screen {
|
||||
(
|
||||
AppState::Onboarding {
|
||||
screen: OnboardingScreen::new(app_event_tx.clone(), config.codex_home.clone()),
|
||||
},
|
||||
Some(ChatWidgetArgs {
|
||||
config: config.clone(),
|
||||
initial_prompt,
|
||||
initial_images,
|
||||
enhanced_keys_supported,
|
||||
}),
|
||||
)
|
||||
} else if show_git_warning {
|
||||
(
|
||||
AppState::GitWarning {
|
||||
screen: GitWarningScreen::new(),
|
||||
@@ -232,12 +254,25 @@ impl App<'_> {
|
||||
AppState::Chat { widget } => {
|
||||
widget.on_ctrl_c();
|
||||
}
|
||||
AppState::Onboarding { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
AppState::GitWarning { .. } => {
|
||||
// Allow exiting the app with Ctrl+C from the warning screen.
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('z'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
widget.on_ctrl_z();
|
||||
}
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('d'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
@@ -255,6 +290,9 @@ impl App<'_> {
|
||||
self.dispatch_key_event(key_event);
|
||||
}
|
||||
}
|
||||
AppState::Onboarding { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
AppState::GitWarning { .. } => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
@@ -282,10 +320,12 @@ impl App<'_> {
|
||||
}
|
||||
AppEvent::CodexOp(op) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.submit_op(op),
|
||||
AppState::Onboarding { .. } => {}
|
||||
AppState::GitWarning { .. } => {}
|
||||
},
|
||||
AppEvent::LatestLog(line) => match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.update_latest_log(line),
|
||||
AppState::Onboarding { .. } => {}
|
||||
AppState::GitWarning { .. } => {}
|
||||
},
|
||||
AppEvent::DispatchCommand(command) => match command {
|
||||
@@ -382,6 +422,12 @@ impl App<'_> {
|
||||
}));
|
||||
}
|
||||
},
|
||||
AppEvent::OnboardingAuthComplete(result) => {
|
||||
if let AppState::Onboarding { screen } = &mut self.app_state {
|
||||
// Let the onboarding screen handle success/failure and emit follow-up events.
|
||||
let _ = screen.on_auth_complete(result);
|
||||
}
|
||||
}
|
||||
AppEvent::StartFileSearch(query) => {
|
||||
self.file_search.on_user_query(query);
|
||||
}
|
||||
@@ -400,6 +446,7 @@ impl App<'_> {
|
||||
pub(crate) fn token_usage(&self) -> codex_core::protocol::TokenUsage {
|
||||
match &self.app_state {
|
||||
AppState::Chat { widget } => widget.token_usage().clone(),
|
||||
AppState::Onboarding { .. } => codex_core::protocol::TokenUsage::default(),
|
||||
AppState::GitWarning { .. } => codex_core::protocol::TokenUsage::default(),
|
||||
}
|
||||
}
|
||||
@@ -428,6 +475,7 @@ impl App<'_> {
|
||||
let size = terminal.size()?;
|
||||
let desired_height = match &self.app_state {
|
||||
AppState::Chat { widget } => widget.desired_height(size.width),
|
||||
AppState::Onboarding { .. } => size.height,
|
||||
AppState::GitWarning { .. } => size.height,
|
||||
};
|
||||
|
||||
@@ -458,6 +506,7 @@ impl App<'_> {
|
||||
}
|
||||
frame.render_widget_ref(&**widget, frame.area())
|
||||
}
|
||||
AppState::Onboarding { screen } => frame.render_widget_ref(&*screen, frame.area()),
|
||||
AppState::GitWarning { screen } => frame.render_widget_ref(&*screen, frame.area()),
|
||||
})?;
|
||||
Ok(())
|
||||
@@ -470,6 +519,25 @@ impl App<'_> {
|
||||
AppState::Chat { widget } => {
|
||||
widget.handle_key_event(key_event);
|
||||
}
|
||||
AppState::Onboarding { screen } => match screen.handle_key_event(key_event) {
|
||||
KeyEventResult::Continue => {
|
||||
self.app_state = AppState::Chat {
|
||||
widget: Box::new(ChatWidget::new(
|
||||
self.config.clone(),
|
||||
self.app_event_tx.clone(),
|
||||
None,
|
||||
Vec::new(),
|
||||
self.enhanced_keys_supported,
|
||||
)),
|
||||
};
|
||||
}
|
||||
KeyEventResult::Quit => {
|
||||
self.app_event_tx.send(AppEvent::ExitRequest);
|
||||
}
|
||||
KeyEventResult::None => {
|
||||
// do nothing
|
||||
}
|
||||
},
|
||||
AppState::GitWarning { screen } => match screen.handle_key_event(key_event) {
|
||||
GitWarningOutcome::Continue => {
|
||||
// User accepted – switch to chat view.
|
||||
@@ -501,6 +569,7 @@ impl App<'_> {
|
||||
fn dispatch_paste_event(&mut self, pasted: String) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_paste(pasted),
|
||||
AppState::Onboarding { .. } => {}
|
||||
AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -508,6 +577,7 @@ impl App<'_> {
|
||||
fn dispatch_codex_event(&mut self, event: Event) {
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => widget.handle_codex_event(event),
|
||||
AppState::Onboarding { .. } => {}
|
||||
AppState::GitWarning { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,4 +48,7 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
|
||||
InsertHistory(Vec<Line<'static>>),
|
||||
|
||||
/// Onboarding: result of login_with_chatgpt.
|
||||
OnboardingAuthComplete(Result<(), String>),
|
||||
}
|
||||
|
||||
@@ -453,6 +453,8 @@ impl ChatComposer {
|
||||
new_text.push_str(&text[end_idx..]);
|
||||
|
||||
self.textarea.set_text(&new_text);
|
||||
let new_cursor = start_idx.saturating_add(path.len()).saturating_add(1);
|
||||
self.textarea.set_cursor(new_cursor);
|
||||
}
|
||||
|
||||
/// Handle key event when no popup is visible.
|
||||
|
||||
@@ -206,7 +206,10 @@ impl TextArea {
|
||||
match event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char(c),
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT,
|
||||
// Insert plain characters (and Shift-modified). Do NOT insert when ALT is held,
|
||||
// because many terminals map Option/Meta combos to ALT+<char> (e.g. ESC f/ESC b)
|
||||
// for word navigation. Those are handled explicitly below.
|
||||
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT,
|
||||
..
|
||||
} => self.insert_str(&c.to_string()),
|
||||
KeyEvent {
|
||||
@@ -245,6 +248,23 @@ impl TextArea {
|
||||
} => {
|
||||
self.delete_backward_word();
|
||||
}
|
||||
// Meta-b -> move to beginning of previous word
|
||||
// Meta-f -> move to end of next word
|
||||
// Many terminals map Option (macOS) to Alt. Some send Alt|Shift, so match contains(ALT).
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('b'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('f'),
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('u'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
@@ -275,6 +295,23 @@ impl TextArea {
|
||||
} => {
|
||||
self.move_cursor_right();
|
||||
}
|
||||
// Some terminals send Alt+Arrow for word-wise movement:
|
||||
// Option/Left -> Alt+Left (previous word start)
|
||||
// Option/Right -> Alt+Right (next word end)
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Up, ..
|
||||
} => {
|
||||
@@ -312,20 +349,6 @@ impl TextArea {
|
||||
} => {
|
||||
self.move_cursor_to_end_of_line(true);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Left,
|
||||
modifiers: KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.beginning_of_previous_word());
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Right,
|
||||
modifiers: KeyModifiers::CONTROL | KeyModifiers::ALT,
|
||||
..
|
||||
} => {
|
||||
self.set_cursor(self.end_of_next_word());
|
||||
}
|
||||
o => {
|
||||
tracing::debug!("Unhandled key event in TextArea: {:?}", o);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,22 @@ fn create_initial_user_message(text: String, image_paths: Vec<PathBuf>) -> Optio
|
||||
}
|
||||
|
||||
impl ChatWidget<'_> {
|
||||
fn interrupt_running_task(&mut self) {
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.active_history_cell = None;
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.bottom_pane.clear_live_ring();
|
||||
self.live_builder = RowBuilder::new(self.live_builder.width());
|
||||
self.current_stream = None;
|
||||
self.stream_header_emitted = false;
|
||||
self.answer_buffer.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
self.content_buffer.clear();
|
||||
self.request_redraw();
|
||||
}
|
||||
}
|
||||
fn layout_areas(&self, area: Rect) -> [Rect; 2] {
|
||||
Layout::vertical([
|
||||
Constraint::Max(
|
||||
@@ -284,7 +300,12 @@ impl ChatWidget<'_> {
|
||||
self.bottom_pane
|
||||
.set_history_metadata(event.history_log_id, event.history_entry_count);
|
||||
// Record session information at the top of the conversation.
|
||||
self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
|
||||
self.add_to_history(HistoryCell::new_session_info(
|
||||
&self.config,
|
||||
event,
|
||||
true,
|
||||
self.app_event_tx.clone(),
|
||||
));
|
||||
|
||||
if let Some(user_message) = self.initial_user_message.take() {
|
||||
// If the user provided an initial message, add it to the
|
||||
@@ -469,7 +490,7 @@ impl ChatWidget<'_> {
|
||||
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
|
||||
call_id,
|
||||
exit_code,
|
||||
duration,
|
||||
duration: _,
|
||||
stdout,
|
||||
stderr,
|
||||
}) => {
|
||||
@@ -482,7 +503,6 @@ impl ChatWidget<'_> {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
duration,
|
||||
},
|
||||
));
|
||||
}
|
||||
@@ -569,18 +589,7 @@ impl ChatWidget<'_> {
|
||||
CancellationEvent::Ignored => {}
|
||||
}
|
||||
if self.bottom_pane.is_task_running() {
|
||||
self.active_history_cell = None;
|
||||
self.bottom_pane.clear_ctrl_c_quit_hint();
|
||||
self.submit_op(Op::Interrupt);
|
||||
self.bottom_pane.set_task_running(false);
|
||||
self.bottom_pane.clear_live_ring();
|
||||
self.live_builder = RowBuilder::new(self.live_builder.width());
|
||||
self.current_stream = None;
|
||||
self.stream_header_emitted = false;
|
||||
self.answer_buffer.clear();
|
||||
self.reasoning_buffer.clear();
|
||||
self.content_buffer.clear();
|
||||
self.request_redraw();
|
||||
self.interrupt_running_task();
|
||||
CancellationEvent::Ignored
|
||||
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
|
||||
self.submit_op(Op::Shutdown);
|
||||
@@ -591,6 +600,10 @@ impl ChatWidget<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn on_ctrl_z(&mut self) {
|
||||
self.interrupt_running_task();
|
||||
}
|
||||
|
||||
pub(crate) fn composer_is_empty(&self) -> bool {
|
||||
self.bottom_pane.composer_is_empty()
|
||||
}
|
||||
|
||||
4
codex-rs/tui/src/colors.rs
Normal file
4
codex-rs/tui/src/colors.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
use ratatui::style::Color;
|
||||
|
||||
pub(crate) const LIGHT_BLUE: Color = Color::Rgb(134, 238, 255);
|
||||
pub(crate) const SUCCESS_GREEN: Color = Color::Rgb(169, 230, 158);
|
||||
@@ -1,4 +1,8 @@
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::exec_command::relativize_to_home;
|
||||
use crate::exec_command::strip_bash_lc_and_escape;
|
||||
use crate::slash_command::SlashCommand;
|
||||
use crate::text_block::TextBlock;
|
||||
use crate::text_formatting::format_and_truncate_tool_result;
|
||||
use base64::Engine;
|
||||
@@ -30,15 +34,19 @@ use std::collections::HashMap;
|
||||
use std::io::Cursor;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
use tracing::error;
|
||||
|
||||
pub(crate) struct CommandOutput {
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) stdout: String,
|
||||
pub(crate) stderr: String,
|
||||
pub(crate) duration: Duration,
|
||||
}
|
||||
|
||||
const WELCOME_FRAMES: [&str; 10] = [">_", ">_", ">_", "-_", ">_", "-_", ">_", ">_", ">_", ">_"];
|
||||
|
||||
const WELCOME_FRAME_DURATION_MS: u64 = 200;
|
||||
|
||||
pub(crate) enum PatchEventType {
|
||||
ApprovalRequest,
|
||||
ApplyBegin { auto_approved: bool },
|
||||
@@ -64,7 +72,10 @@ fn line_to_static(line: &Line) -> Line<'static> {
|
||||
/// scrollable list.
|
||||
pub(crate) enum HistoryCell {
|
||||
/// Welcome message.
|
||||
WelcomeMessage { view: TextBlock },
|
||||
WelcomeMessage {
|
||||
view: TextBlock,
|
||||
start_time: Instant,
|
||||
},
|
||||
|
||||
/// Message from the user.
|
||||
UserPrompt { view: TextBlock },
|
||||
@@ -120,7 +131,7 @@ pub(crate) enum HistoryCell {
|
||||
PatchApplyResult { view: TextBlock },
|
||||
}
|
||||
|
||||
const TOOL_CALL_MAX_LINES: usize = 5;
|
||||
const TOOL_CALL_MAX_LINES: usize = 3;
|
||||
|
||||
impl HistoryCell {
|
||||
/// Return a cloned, plain representation of the cell's lines suitable for
|
||||
@@ -128,7 +139,7 @@ impl HistoryCell {
|
||||
/// represented with a simple placeholder for now.
|
||||
pub(crate) fn plain_lines(&self) -> Vec<Line<'static>> {
|
||||
match self {
|
||||
HistoryCell::WelcomeMessage { view }
|
||||
HistoryCell::WelcomeMessage { view, .. }
|
||||
| HistoryCell::UserPrompt { view }
|
||||
| HistoryCell::BackgroundEvent { view }
|
||||
| HistoryCell::GitDiffOutput { view }
|
||||
@@ -163,37 +174,52 @@ impl HistoryCell {
|
||||
config: &Config,
|
||||
event: SessionConfiguredEvent,
|
||||
is_first_event: bool,
|
||||
app_event_tx: AppEventSender,
|
||||
) -> Self {
|
||||
let SessionConfiguredEvent {
|
||||
model,
|
||||
session_id,
|
||||
session_id: _,
|
||||
history_log_id: _,
|
||||
history_entry_count: _,
|
||||
} = event;
|
||||
if is_first_event {
|
||||
const VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
let cwd_str = match relativize_to_home(&config.cwd) {
|
||||
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
|
||||
Some(_) => "~".to_string(),
|
||||
None => config.cwd.display().to_string(),
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line<'static>> = vec![
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from(vec![
|
||||
"OpenAI ".into(),
|
||||
"Codex".bold(),
|
||||
format!(" v{VERSION}").into(),
|
||||
" (research preview)".dim(),
|
||||
]),
|
||||
Line::from(""),
|
||||
Line::from(vec![
|
||||
"codex session".magenta().bold(),
|
||||
" ".into(),
|
||||
session_id.to_string().dim(),
|
||||
Span::raw(">_ ").dim(),
|
||||
Span::styled(
|
||||
"You are using OpenAI Codex in",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!(" {cwd_str}")).dim(),
|
||||
]),
|
||||
Line::from("".dim()),
|
||||
Line::from(" Try one of the following commands to get started:".dim()),
|
||||
Line::from("".dim()),
|
||||
Line::from(format!(" 1. /init - {}", SlashCommand::Init.description()).dim()),
|
||||
Line::from(format!(" 2. /status - {}", SlashCommand::Status.description()).dim()),
|
||||
Line::from(format!(" 3. /compact - {}", SlashCommand::Compact.description()).dim()),
|
||||
Line::from(format!(" 4. /new - {}", SlashCommand::New.description()).dim()),
|
||||
Line::from("".dim()),
|
||||
];
|
||||
|
||||
for (key, value) in create_config_summary_entries(config) {
|
||||
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
|
||||
let start_time = Instant::now();
|
||||
{
|
||||
let tx = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
for _ in 0..WELCOME_FRAMES.len() {
|
||||
std::thread::sleep(Duration::from_millis(WELCOME_FRAME_DURATION_MS));
|
||||
tx.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
});
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
HistoryCell::WelcomeMessage {
|
||||
view: TextBlock::new(lines),
|
||||
start_time,
|
||||
}
|
||||
} else if config.model == model {
|
||||
HistoryCell::SessionInfo {
|
||||
@@ -227,8 +253,11 @@ impl HistoryCell {
|
||||
let command_escaped = strip_bash_lc_and_escape(&command);
|
||||
|
||||
let lines: Vec<Line<'static>> = vec![
|
||||
Line::from(vec!["command".magenta(), " running...".dim()]),
|
||||
Line::from(format!("$ {command_escaped}")),
|
||||
Line::from(vec![
|
||||
"▌ ".cyan(),
|
||||
"Running command ".magenta(),
|
||||
command_escaped.into(),
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
@@ -242,34 +271,36 @@ impl HistoryCell {
|
||||
exit_code,
|
||||
stdout,
|
||||
stderr,
|
||||
duration,
|
||||
} = output;
|
||||
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
|
||||
// Title depends on whether we have output yet.
|
||||
let title_line = Line::from(vec![
|
||||
"command".magenta(),
|
||||
format!(
|
||||
" (code: {}, duration: {})",
|
||||
exit_code,
|
||||
format_duration(duration)
|
||||
)
|
||||
.dim(),
|
||||
]);
|
||||
lines.push(title_line);
|
||||
let command_escaped = strip_bash_lc_and_escape(&command);
|
||||
lines.push(Line::from(vec![
|
||||
"⚡Ran command ".magenta(),
|
||||
command_escaped.into(),
|
||||
]));
|
||||
|
||||
let src = if exit_code == 0 { stdout } else { stderr };
|
||||
|
||||
let cmdline = strip_bash_lc_and_escape(&command);
|
||||
lines.push(Line::from(format!("$ {cmdline}")));
|
||||
let mut lines_iter = src.lines();
|
||||
for raw in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES) {
|
||||
lines.push(ansi_escape_line(raw).dim());
|
||||
for (idx, raw) in lines_iter.by_ref().take(TOOL_CALL_MAX_LINES).enumerate() {
|
||||
let mut line = ansi_escape_line(raw);
|
||||
let prefix = if idx == 0 { " ⎿ " } else { " " };
|
||||
line.spans.insert(0, prefix.into());
|
||||
line.spans.iter_mut().for_each(|span| {
|
||||
span.style = span.style.add_modifier(Modifier::DIM);
|
||||
});
|
||||
lines.push(line);
|
||||
}
|
||||
let remaining = lines_iter.count();
|
||||
if remaining > 0 {
|
||||
lines.push(Line::from(format!("... {remaining} additional lines")).dim());
|
||||
let mut more = Line::from(format!("... +{remaining} lines"));
|
||||
// Continuation/ellipsis is treated as a subsequent line for prefixing
|
||||
more.spans.insert(0, " ".into());
|
||||
more.spans.iter_mut().for_each(|span| {
|
||||
span.style = span.style.add_modifier(Modifier::DIM);
|
||||
});
|
||||
lines.push(more);
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
|
||||
@@ -697,9 +728,30 @@ impl HistoryCell {
|
||||
|
||||
impl WidgetRef for &HistoryCell {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
Paragraph::new(Text::from(self.plain_lines()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
match self {
|
||||
HistoryCell::WelcomeMessage { view, start_time } => {
|
||||
let mut lines = view.lines.clone();
|
||||
if let Some(first_line) = lines.get_mut(0) {
|
||||
if let Some(span0) = first_line.spans.get_mut(0) {
|
||||
let style = span0.style.clone();
|
||||
let elapsed = start_time.elapsed().as_millis();
|
||||
let idx = (elapsed / WELCOME_FRAME_DURATION_MS as u128) as usize;
|
||||
let frame = *WELCOME_FRAMES
|
||||
.get(idx)
|
||||
.unwrap_or_else(|| WELCOME_FRAMES.last().unwrap());
|
||||
*span0 = Span::styled(format!("{frame} "), style);
|
||||
}
|
||||
}
|
||||
Paragraph::new(Text::from(lines))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
_ => {
|
||||
Paragraph::new(Text::from(self.plain_lines()))
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,6 @@ use codex_login::load_auth;
|
||||
use codex_ollama::DEFAULT_OSS_MODEL;
|
||||
use log_layer::TuiLogLayer;
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
use tracing::error;
|
||||
use tracing_appender::non_blocking;
|
||||
@@ -27,6 +26,7 @@ mod bottom_pane;
|
||||
mod chatwidget;
|
||||
mod citation_regex;
|
||||
mod cli;
|
||||
mod colors;
|
||||
pub mod custom_terminal;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
@@ -37,6 +37,8 @@ pub mod insert_history;
|
||||
pub mod live_wrap;
|
||||
mod log_layer;
|
||||
mod markdown;
|
||||
pub mod onboarding;
|
||||
mod shimmer;
|
||||
mod slash_command;
|
||||
mod status_indicator_widget;
|
||||
mod text_block;
|
||||
@@ -204,24 +206,6 @@ pub async fn run_main(
|
||||
eprintln!("");
|
||||
}
|
||||
|
||||
let show_login_screen = should_show_login_screen(&config);
|
||||
if show_login_screen {
|
||||
std::io::stdout()
|
||||
.write_all(b"No API key detected.\nLogin with your ChatGPT account? [Yn] ")?;
|
||||
std::io::stdout().flush()?;
|
||||
let mut input = String::new();
|
||||
std::io::stdin().read_line(&mut input)?;
|
||||
let trimmed = input.trim();
|
||||
if !(trimmed.is_empty() || trimmed.eq_ignore_ascii_case("y")) {
|
||||
std::process::exit(1);
|
||||
}
|
||||
// Spawn a task to run the login command.
|
||||
// Block until the login command is finished.
|
||||
codex_login::login_with_chatgpt(&config.codex_home, false).await?;
|
||||
|
||||
std::io::stdout().write_all(b"Login successful.\n")?;
|
||||
}
|
||||
|
||||
// Determine whether we need to display the "not a git repo" warning
|
||||
// modal. The flag is shown when the current working directory is *not*
|
||||
// inside a Git repository **and** the user did *not* pass the
|
||||
|
||||
@@ -22,7 +22,9 @@ fn main() -> anyhow::Result<()> {
|
||||
.raw_overrides
|
||||
.splice(0..0, top_cli.config_overrides.raw_overrides);
|
||||
let usage = run_main(inner, codex_linux_sandbox_exe).await?;
|
||||
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||
if !usage.is_zero() {
|
||||
println!("{}", codex_core::protocol::FinalOutput::from(usage));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
316
codex-rs/tui/src/onboarding/auth.rs
Normal file
316
codex-rs/tui/src/onboarding/auth.rs
Normal file
@@ -0,0 +1,316 @@
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::widgets::Wrap;
|
||||
|
||||
use codex_login::AuthMode;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::colors::LIGHT_BLUE;
|
||||
use crate::colors::SUCCESS_GREEN;
|
||||
use crate::onboarding::onboarding_screen::KeyEventResult;
|
||||
use crate::onboarding::onboarding_screen::KeyboardHandler;
|
||||
use crate::shimmer::FrameTicker;
|
||||
use crate::shimmer::shimmer_spans;
|
||||
use std::path::PathBuf;
|
||||
// no additional imports
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) enum SignInState {
|
||||
PickMode,
|
||||
ChatGptContinueInBrowser(#[allow(dead_code)] ContinueInBrowserState),
|
||||
ChatGptSuccess,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// Used to manage the lifecycle of SpawnedLogin and FrameTicker and ensure they get cleaned up.
|
||||
pub(crate) struct ContinueInBrowserState {
|
||||
_login_child: Option<codex_login::SpawnedLogin>,
|
||||
_frame_ticker: Option<FrameTicker>,
|
||||
}
|
||||
|
||||
impl Drop for ContinueInBrowserState {
|
||||
fn drop(&mut self) {
|
||||
if let Some(child) = &self._login_child {
|
||||
if let Ok(mut locked) = child.child.lock() {
|
||||
// Best-effort terminate and reap the child to avoid zombies.
|
||||
let _ = locked.kill();
|
||||
let _ = locked.wait();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for AuthModeWidget {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
|
||||
match key_event.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => {
|
||||
self.mode = AuthMode::ChatGPT;
|
||||
KeyEventResult::None
|
||||
}
|
||||
KeyCode::Down | KeyCode::Char('j') => {
|
||||
self.mode = AuthMode::ApiKey;
|
||||
KeyEventResult::None
|
||||
}
|
||||
KeyCode::Char('1') => {
|
||||
self.mode = AuthMode::ChatGPT;
|
||||
self.start_chatgpt_login();
|
||||
KeyEventResult::None
|
||||
}
|
||||
KeyCode::Char('2') => {
|
||||
self.mode = AuthMode::ApiKey;
|
||||
self.verify_api_key()
|
||||
}
|
||||
KeyCode::Enter => match self.mode {
|
||||
AuthMode::ChatGPT => match &self.sign_in_state {
|
||||
SignInState::PickMode => self.start_chatgpt_login(),
|
||||
SignInState::ChatGptContinueInBrowser(_) => KeyEventResult::None,
|
||||
SignInState::ChatGptSuccess => KeyEventResult::Continue,
|
||||
},
|
||||
AuthMode::ApiKey => self.verify_api_key(),
|
||||
},
|
||||
KeyCode::Esc => {
|
||||
if matches!(self.sign_in_state, SignInState::ChatGptContinueInBrowser(_)) {
|
||||
self.sign_in_state = SignInState::PickMode;
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
KeyEventResult::None
|
||||
} else {
|
||||
KeyEventResult::Quit
|
||||
}
|
||||
}
|
||||
KeyCode::Char('q') => KeyEventResult::Quit,
|
||||
_ => KeyEventResult::None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct AuthModeWidget {
|
||||
pub mode: AuthMode,
|
||||
pub error: Option<String>,
|
||||
pub sign_in_state: SignInState,
|
||||
pub event_tx: AppEventSender,
|
||||
pub codex_home: PathBuf,
|
||||
}
|
||||
|
||||
impl AuthModeWidget {
|
||||
fn render_pick_mode(&self, area: Rect, buf: &mut Buffer) {
|
||||
let mut lines: Vec<Line> = vec![
|
||||
Line::from(vec![
|
||||
Span::raw("> "),
|
||||
Span::styled(
|
||||
"Sign in with your ChatGPT account?",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
let create_mode_item = |idx: usize,
|
||||
selected_mode: AuthMode,
|
||||
text: &str,
|
||||
description: &str|
|
||||
-> Vec<Line<'static>> {
|
||||
let is_selected = self.mode == selected_mode;
|
||||
let caret = if is_selected { ">" } else { " " };
|
||||
|
||||
let line1 = if is_selected {
|
||||
Line::from(vec![
|
||||
Span::styled(
|
||||
format!("{} {}. ", caret, idx + 1),
|
||||
Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM),
|
||||
),
|
||||
Span::styled(text.to_owned(), Style::default().fg(LIGHT_BLUE)),
|
||||
])
|
||||
} else {
|
||||
Line::from(format!(" {}. {text}", idx + 1))
|
||||
};
|
||||
|
||||
let line2 = if is_selected {
|
||||
Line::from(format!(" {description}"))
|
||||
.style(Style::default().fg(LIGHT_BLUE).add_modifier(Modifier::DIM))
|
||||
} else {
|
||||
Line::from(format!(" {description}"))
|
||||
.style(Style::default().add_modifier(Modifier::DIM))
|
||||
};
|
||||
|
||||
vec![line1, line2]
|
||||
};
|
||||
|
||||
lines.extend(create_mode_item(
|
||||
0,
|
||||
AuthMode::ChatGPT,
|
||||
"Sign in with ChatGPT or create a new account",
|
||||
"Leverages your plan, starting at $20 a month for Plus",
|
||||
));
|
||||
lines.extend(create_mode_item(
|
||||
1,
|
||||
AuthMode::ApiKey,
|
||||
"Provide your own API key",
|
||||
"Pay only for what you use",
|
||||
));
|
||||
lines.push(Line::from(""));
|
||||
lines.push(
|
||||
Line::from("Press Enter to continue")
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
);
|
||||
if let Some(err) = &self.error {
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(Span::styled(
|
||||
err.as_str(),
|
||||
Style::default().fg(Color::Red),
|
||||
)));
|
||||
}
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_continue_in_browser(&self, area: Rect, buf: &mut Buffer) {
|
||||
let idx = self.current_frame();
|
||||
let mut spans = vec![Span::from("> ")];
|
||||
spans.extend(shimmer_spans("Finish signing in via your browser", idx));
|
||||
let lines = vec![
|
||||
Line::from(spans),
|
||||
Line::from(""),
|
||||
Line::from(" Press Escape to cancel")
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
];
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn render_chatgpt_success(&self, area: Rect, buf: &mut Buffer) {
|
||||
let lines = vec![
|
||||
Line::from("✓ Signed in with your ChatGPT account")
|
||||
.style(Style::default().fg(SUCCESS_GREEN)),
|
||||
Line::from(""),
|
||||
Line::from("> Before you start:"),
|
||||
Line::from(""),
|
||||
Line::from(" Codex can make mistakes"),
|
||||
Line::from(" Check important info")
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
Line::from(""),
|
||||
Line::from(" Due to prompt injection risks, only use it with code you trust"),
|
||||
Line::from(" For more details see https://github.com/openai/codex")
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
Line::from(""),
|
||||
Line::from(" Powered by your ChatGPT account"),
|
||||
Line::from(" Uses your plan's rate limits and training data preferences")
|
||||
.style(Style::default().add_modifier(Modifier::DIM)),
|
||||
Line::from(""),
|
||||
Line::from(" Press Enter to continue").style(Style::default().fg(LIGHT_BLUE)),
|
||||
];
|
||||
|
||||
Paragraph::new(lines)
|
||||
.wrap(Wrap { trim: false })
|
||||
.render(area, buf);
|
||||
}
|
||||
|
||||
fn start_chatgpt_login(&mut self) -> KeyEventResult {
|
||||
self.error = None;
|
||||
match codex_login::spawn_login_with_chatgpt(&self.codex_home) {
|
||||
Ok(child) => {
|
||||
self.spawn_completion_poller(child.clone());
|
||||
self.sign_in_state =
|
||||
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
|
||||
_login_child: Some(child),
|
||||
_frame_ticker: Some(FrameTicker::new(self.event_tx.clone())),
|
||||
});
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
KeyEventResult::None
|
||||
}
|
||||
Err(e) => {
|
||||
self.sign_in_state = SignInState::PickMode;
|
||||
self.error = Some(e.to_string());
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
KeyEventResult::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// TODO: Read/write from the correct hierarchy config overrides + auth json + OPENAI_API_KEY.
|
||||
fn verify_api_key(&mut self) -> KeyEventResult {
|
||||
if std::env::var("OPENAI_API_KEY").is_err() {
|
||||
self.error =
|
||||
Some("Set OPENAI_API_KEY in your environment. Learn more: https://platform.openai.com/docs/libraries".to_string());
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
KeyEventResult::None
|
||||
} else {
|
||||
KeyEventResult::Continue
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_completion_poller(&self, child: codex_login::SpawnedLogin) {
|
||||
let child_arc = child.child.clone();
|
||||
let stderr_buf = child.stderr.clone();
|
||||
let event_tx = self.event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
let done = {
|
||||
if let Ok(mut locked) = child_arc.lock() {
|
||||
match locked.try_wait() {
|
||||
Ok(Some(status)) => Some(status.success()),
|
||||
Ok(None) => None,
|
||||
Err(_) => Some(false),
|
||||
}
|
||||
} else {
|
||||
Some(false)
|
||||
}
|
||||
};
|
||||
if let Some(success) = done {
|
||||
if success {
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Ok(())));
|
||||
} else {
|
||||
let err = stderr_buf
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|b| String::from_utf8(b.clone()).ok())
|
||||
.unwrap_or_else(|| "login_with_chatgpt subprocess failed".to_string());
|
||||
event_tx.send(AppEvent::OnboardingAuthComplete(Err(err)));
|
||||
}
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(250));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn current_frame(&self) -> usize {
|
||||
// Derive frame index from wall-clock time to avoid storing animation state.
|
||||
// 100ms per frame to match the previous ticker cadence.
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis())
|
||||
.unwrap_or(0);
|
||||
(now_ms / 100) as usize
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for AuthModeWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match self.sign_in_state {
|
||||
SignInState::PickMode => {
|
||||
self.render_pick_mode(area, buf);
|
||||
}
|
||||
SignInState::ChatGptContinueInBrowser(_) => {
|
||||
self.render_continue_in_browser(area, buf);
|
||||
}
|
||||
SignInState::ChatGptSuccess => {
|
||||
self.render_chatgpt_success(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
3
codex-rs/tui/src/onboarding/mod.rs
Normal file
3
codex-rs/tui/src/onboarding/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
mod auth;
|
||||
pub mod onboarding_screen;
|
||||
mod welcome;
|
||||
157
codex-rs/tui/src/onboarding/onboarding_screen.rs
Normal file
157
codex-rs/tui/src/onboarding/onboarding_screen.rs
Normal file
@@ -0,0 +1,157 @@
|
||||
use crossterm::event::KeyEvent;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
use codex_login::AuthMode;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::onboarding::auth::AuthModeWidget;
|
||||
use crate::onboarding::auth::SignInState;
|
||||
use crate::onboarding::welcome::WelcomeWidget;
|
||||
use std::path::PathBuf;
|
||||
|
||||
enum Step {
|
||||
Welcome(WelcomeWidget),
|
||||
Auth(AuthModeWidget),
|
||||
}
|
||||
|
||||
pub(crate) trait KeyboardHandler {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult;
|
||||
}
|
||||
|
||||
pub(crate) enum KeyEventResult {
|
||||
Continue,
|
||||
Quit,
|
||||
None,
|
||||
}
|
||||
|
||||
pub(crate) struct OnboardingScreen {
|
||||
event_tx: AppEventSender,
|
||||
steps: Vec<Step>,
|
||||
}
|
||||
|
||||
impl OnboardingScreen {
|
||||
pub(crate) fn new(event_tx: AppEventSender, codex_home: PathBuf) -> Self {
|
||||
let steps: Vec<Step> = vec![
|
||||
Step::Welcome(WelcomeWidget {}),
|
||||
Step::Auth(AuthModeWidget {
|
||||
event_tx: event_tx.clone(),
|
||||
mode: AuthMode::ChatGPT,
|
||||
error: None,
|
||||
sign_in_state: SignInState::PickMode,
|
||||
codex_home,
|
||||
}),
|
||||
];
|
||||
Self { event_tx, steps }
|
||||
}
|
||||
|
||||
pub(crate) fn on_auth_complete(&mut self, result: Result<(), String>) -> KeyEventResult {
|
||||
if let Some(Step::Auth(state)) = self.steps.last_mut() {
|
||||
match result {
|
||||
Ok(()) => {
|
||||
state.sign_in_state = SignInState::ChatGptSuccess;
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
KeyEventResult::None
|
||||
}
|
||||
Err(e) => {
|
||||
state.sign_in_state = SignInState::PickMode;
|
||||
state.error = Some(e);
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
KeyEventResult::None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
KeyEventResult::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for OnboardingScreen {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
|
||||
if let Some(last_step) = self.steps.last_mut() {
|
||||
self.event_tx.send(AppEvent::RequestRedraw);
|
||||
last_step.handle_key_event(key_event)
|
||||
} else {
|
||||
KeyEventResult::None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for &OnboardingScreen {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
// Render steps top-to-bottom, measuring each step's height dynamically.
|
||||
let mut y = area.y;
|
||||
let bottom = area.y.saturating_add(area.height);
|
||||
let width = area.width;
|
||||
|
||||
// Helper to scan a temporary buffer and return number of used rows.
|
||||
fn used_rows(tmp: &Buffer, width: u16, height: u16) -> u16 {
|
||||
if width == 0 || height == 0 {
|
||||
return 0;
|
||||
}
|
||||
let mut last_non_empty: Option<u16> = None;
|
||||
for yy in 0..height {
|
||||
let mut any = false;
|
||||
for xx in 0..width {
|
||||
let sym = tmp[(xx, yy)].symbol();
|
||||
if !sym.trim().is_empty() {
|
||||
any = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if any {
|
||||
last_non_empty = Some(yy);
|
||||
}
|
||||
}
|
||||
last_non_empty.map(|v| v + 2).unwrap_or(0)
|
||||
}
|
||||
|
||||
let mut i = 0usize;
|
||||
while i < self.steps.len() && y < bottom {
|
||||
let step = &self.steps[i];
|
||||
let max_h = bottom.saturating_sub(y);
|
||||
if max_h == 0 || width == 0 {
|
||||
break;
|
||||
}
|
||||
let scratch_area = Rect::new(0, 0, width, max_h);
|
||||
let mut scratch = Buffer::empty(scratch_area);
|
||||
step.render_ref(scratch_area, &mut scratch);
|
||||
let h = used_rows(&scratch, width, max_h).min(max_h);
|
||||
if h > 0 {
|
||||
let target = Rect {
|
||||
x: area.x,
|
||||
y,
|
||||
width,
|
||||
height: h,
|
||||
};
|
||||
step.render_ref(target, buf);
|
||||
y = y.saturating_add(h);
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl KeyboardHandler for Step {
|
||||
fn handle_key_event(&mut self, key_event: KeyEvent) -> KeyEventResult {
|
||||
match self {
|
||||
Step::Welcome(_) => KeyEventResult::None,
|
||||
Step::Auth(widget) => widget.handle_key_event(key_event),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl WidgetRef for Step {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
match self {
|
||||
Step::Welcome(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
Step::Auth(widget) => {
|
||||
widget.render_ref(area, buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
codex-rs/tui/src/onboarding/welcome.rs
Normal file
23
codex-rs/tui/src/onboarding/welcome.rs
Normal file
@@ -0,0 +1,23 @@
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::prelude::Widget;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
pub(crate) struct WelcomeWidget {}
|
||||
|
||||
impl WidgetRef for &WelcomeWidget {
|
||||
fn render_ref(&self, area: Rect, buf: &mut Buffer) {
|
||||
let line = Line::from(vec![
|
||||
Span::raw("> "),
|
||||
Span::styled(
|
||||
"Welcome to Codex, OpenAI's coding agent that runs in your terminal",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
line.render(area, buf);
|
||||
}
|
||||
}
|
||||
84
codex-rs/tui/src/shimmer.rs
Normal file
84
codex-rs/tui/src/shimmer.rs
Normal file
@@ -0,0 +1,84 @@
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::atomic::Ordering;
|
||||
use std::time::Duration;
|
||||
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Modifier;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct FrameTicker {
|
||||
running: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl FrameTicker {
|
||||
pub(crate) fn new(app_event_tx: AppEventSender) -> Self {
|
||||
let running = Arc::new(AtomicBool::new(true));
|
||||
let running_clone = running.clone();
|
||||
let app_event_tx_clone = app_event_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
while running_clone.load(Ordering::Relaxed) {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
app_event_tx_clone.send(AppEvent::RequestRedraw);
|
||||
}
|
||||
});
|
||||
Self { running }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for FrameTicker {
|
||||
fn drop(&mut self) {
|
||||
self.running.store(false, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn shimmer_spans(text: &str, frame_idx: usize) -> Vec<Span<'static>> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
let padding = 10usize;
|
||||
let period = chars.len() + padding * 2;
|
||||
let pos = frame_idx % period;
|
||||
let has_true_color = supports_color::on_cached(supports_color::Stream::Stdout)
|
||||
.map(|level| level.has_16m)
|
||||
.unwrap_or(false);
|
||||
let band_half_width = 6.0;
|
||||
|
||||
let mut spans: Vec<Span<'static>> = Vec::with_capacity(chars.len());
|
||||
for (i, ch) in chars.iter().enumerate() {
|
||||
let i_pos = i as isize + padding as isize;
|
||||
let pos = pos as isize;
|
||||
let dist = (i_pos - pos).abs() as f32;
|
||||
|
||||
let t = if dist <= band_half_width {
|
||||
let x = std::f32::consts::PI * (dist / band_half_width);
|
||||
0.5 * (1.0 + x.cos())
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let brightness = 0.4 + 0.6 * t;
|
||||
let level = (brightness * 255.0).clamp(0.0, 255.0) as u8;
|
||||
let style = if has_true_color {
|
||||
Style::default()
|
||||
.fg(Color::Rgb(level, level, level))
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(color_for_level(level))
|
||||
};
|
||||
spans.push(Span::styled(ch.to_string(), style));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
fn color_for_level(level: u8) -> Color {
|
||||
if level < 128 {
|
||||
Color::DarkGray
|
||||
} else if level < 192 {
|
||||
Color::Gray
|
||||
} else {
|
||||
Color::White
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ impl SlashCommand {
|
||||
pub fn description(self) -> &'static str {
|
||||
match self {
|
||||
SlashCommand::New => "Start a new chat",
|
||||
SlashCommand::Init => "Create an AGENTS.md file with instructions for Codex.",
|
||||
SlashCommand::Init => "Create an AGENTS.md file with instructions for Codex",
|
||||
SlashCommand::Compact => "Compact the chat history",
|
||||
SlashCommand::Quit => "Exit the application",
|
||||
SlashCommand::Diff => "Show git diff (including untracked files)",
|
||||
|
||||
Reference in New Issue
Block a user