Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Bolin
fde2b273d1 feat: include path to rollout file in /status output 2025-08-06 14:59:25 -07:00
29 changed files with 118 additions and 1177 deletions

2
codex-rs/Cargo.lock generated
View File

@@ -895,8 +895,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"tree-sitter",
"tree-sitter-bash",
"tui-input",
"tui-markdown",
"unicode-segmentation",

View File

@@ -42,15 +42,6 @@ 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 {
@@ -375,21 +366,13 @@ pub fn apply_hunks(
match apply_hunks_to_files(hunks) {
Ok(affected) => {
print_summary(&affected, stdout).map_err(ApplyPatchError::from)?;
Ok(())
}
Err(err) => {
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),
}))
}
writeln!(stderr, "{err:?}").map_err(ApplyPatchError::from)?;
}
}
Ok(())
}
/// Applies each parsed patch hunk to the filesystem.
@@ -1255,24 +1238,4 @@ 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());
}
}

View File

@@ -121,9 +121,7 @@ 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?;
if !usage.is_zero() {
println!("{}", codex_core::protocol::FinalOutput::from(usage));
}
println!("{}", codex_core::protocol::FinalOutput::from(usage));
}
Some(Subcommand::Exec(mut exec_cli)) => {
prepend_config_flags(&mut exec_cli.config_overrides, cli.config_overrides);

View File

@@ -127,15 +127,6 @@ 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);

View File

@@ -760,6 +760,10 @@ async fn submission_loop(
}
};
// Determine rollout path if available before moving the recorder.
let rollout_path: Option<std::path::PathBuf> =
rollout_recorder.as_ref().map(|r| r.path().to_path_buf());
let client = ModelClient::new(
config.clone(),
auth.clone(),
@@ -859,6 +863,7 @@ async fn submission_loop(
model,
history_log_id,
history_entry_count,
rollout_path,
}),
})
.chain(mcp_connection_errors.into_iter());

View File

@@ -89,11 +89,6 @@ 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
}

View File

@@ -77,11 +77,6 @@ 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,
}
}

View File

@@ -429,12 +429,6 @@ 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,
@@ -653,6 +647,10 @@ pub struct SessionConfiguredEvent {
/// Current number of entries in the history log.
pub history_entry_count: usize,
/// Absolute path to the rollout file for this session, if recording is enabled.
#[serde(skip_serializing_if = "Option::is_none")]
pub rollout_path: Option<std::path::PathBuf>,
}
/// User's decision in response to an ExecApprovalRequest.
@@ -715,6 +713,7 @@ mod tests {
model: "codex-mini-latest".to_string(),
history_log_id: 0,
history_entry_count: 0,
rollout_path: None,
}),
};
let serialized = serde_json::to_string(&event).unwrap();

View File

@@ -66,6 +66,8 @@ pub struct SavedSession {
#[derive(Clone)]
pub(crate) struct RolloutRecorder {
tx: Sender<RolloutCmd>,
/// Absolute path to the rollout file for this session.
path: std::path::PathBuf,
}
enum RolloutCmd {
@@ -87,6 +89,7 @@ impl RolloutRecorder {
file,
session_id,
timestamp,
path,
} = create_log_file(config, uuid)?;
let timestamp_format: &[FormatItem] = format_description!(
@@ -118,7 +121,7 @@ impl RolloutRecorder {
cwd,
));
Ok(Self { tx })
Ok(Self { tx, path })
}
pub(crate) async fn record_items(&self, items: &[ResponseItem]) -> std::io::Result<()> {
@@ -223,7 +226,13 @@ impl RolloutRecorder {
cwd,
));
info!("Resumed rollout successfully from {path:?}");
Ok((Self { tx }, saved))
Ok((
Self {
tx,
path: path.to_path_buf(),
},
saved,
))
}
pub async fn shutdown(&self) -> std::io::Result<()> {
@@ -240,6 +249,11 @@ impl RolloutRecorder {
}
}
}
/// Return the absolute path to the rollout file recorded by this session.
pub fn path(&self) -> &std::path::Path {
&self.path
}
}
struct LogFileInfo {
@@ -251,6 +265,9 @@ struct LogFileInfo {
/// Timestamp for the start of the session.
timestamp: OffsetDateTime,
/// Absolute path to the rollout file on disk.
path: std::path::PathBuf,
}
fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFileInfo> {
@@ -284,6 +301,7 @@ fn create_log_file(config: &Config, session_id: Uuid) -> std::io::Result<LogFile
file,
session_id,
timestamp,
path,
})
}

View File

@@ -490,10 +490,7 @@ impl EventProcessor for EventProcessorWithHumanOutput {
}
EventMsg::SessionConfigured(session_configured_event) => {
let SessionConfiguredEvent {
session_id,
model,
history_log_id: _,
history_entry_count: _,
session_id, model, ..
} = session_configured_event;
ts_println!(

View File

@@ -4,7 +4,6 @@ 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;
@@ -12,7 +11,6 @@ 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;
@@ -185,59 +183,6 @@ 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
@@ -289,7 +234,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 = File::open(auth_file)?;
let mut file = std::fs::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)?;

View File

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

View File

@@ -908,6 +908,7 @@ mod tests {
model: "codex-mini-latest".into(),
history_log_id: 42,
history_entry_count: 3,
rollout_path: None,
}),
};

View File

@@ -244,6 +244,7 @@ mod tests {
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
rollout_path: None,
}),
};
@@ -284,6 +285,7 @@ mod tests {
model: "gpt-4o".to_string(),
history_log_id: 1,
history_entry_count: 1000,
rollout_path: None,
};
let event = Event {
id: "1".to_string(),

View File

@@ -65,17 +65,17 @@ tokio = { version = "1", features = [
tracing = { version = "0.1.41", features = ["log"] }
tracing-appender = "0.2.3"
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tree-sitter = "0.25.8"
tree-sitter-bash = "0.25.0"
tui-input = "0.14.0"
tui-markdown = "0.3.3"
unicode-segmentation = "1.12.0"
unicode-width = "0.1"
uuid = "1"
[dev-dependencies]
chrono = { version = "0.4", features = ["serde"] }
insta = "1.43.1"
pretty_assertions = "1"
rand = "0.8"
chrono = { version = "0.4", features = ["serde"] }
vt100 = "0.16.2"

View File

@@ -5,10 +5,6 @@ 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;
@@ -39,9 +35,6 @@ 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
@@ -49,9 +42,7 @@ 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> {
@@ -142,20 +133,7 @@ impl App<'_> {
});
}
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 {
let (app_state, chat_args) = if show_git_warning {
(
AppState::GitWarning {
screen: GitWarningScreen::new(),
@@ -254,25 +232,12 @@ 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,
@@ -290,9 +255,6 @@ 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);
}
@@ -320,12 +282,10 @@ 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 {
@@ -422,12 +382,6 @@ 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);
}
@@ -446,7 +400,6 @@ 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(),
}
}
@@ -475,7 +428,6 @@ 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,
};
@@ -506,7 +458,6 @@ 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(())
@@ -519,25 +470,6 @@ 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.
@@ -569,7 +501,6 @@ 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 { .. } => {}
}
}
@@ -577,7 +508,6 @@ 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 { .. } => {}
}
}

View File

@@ -48,7 +48,4 @@ pub(crate) enum AppEvent {
},
InsertHistory(Vec<Line<'static>>),
/// Onboarding: result of login_with_chatgpt.
OnboardingAuthComplete(Result<(), String>),
}

View File

@@ -1,279 +0,0 @@
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use tree_sitter::Parser;
use tree_sitter::Tree;
use tree_sitter_bash::LANGUAGE as BASH;
/// Besteffort syntax highlight for a shell command line.
///
/// Uses tree-sitter-bash (via codex-core) to parse the command and styles
/// common token kinds. Falls back to plain text on parse failure.
fn try_parse_bash(src: &str) -> Option<Tree> {
let lang = BASH.into();
let mut parser = Parser::new();
#[expect(clippy::expect_used)]
parser.set_language(&lang).expect("load bash grammar");
parser.parse(src, None)
}
pub(crate) fn highlight_shell_command_line(src: &str) -> Line<'static> {
let Some(tree) = try_parse_bash(src) else {
return Line::from(src.to_string());
};
// Collect styled segments as byte ranges with a Style.
#[derive(Clone, Copy)]
struct Seg {
start: usize,
end: usize,
style: Style,
}
let mut segs: Vec<Seg> = Vec::new();
let root = tree.root_node();
let mut cursor = root.walk();
let mut stack = vec![root];
while let Some(node) = stack.pop() {
// We only annotate a handful of common node kinds.
let kind = node.kind();
let style = match kind {
// First word of a command (command_name) stands out.
"command_name" => Some(
Style::default()
.fg(Color::Blue)
.add_modifier(Modifier::BOLD),
),
// String literals.
"string" | "raw_string" => Some(Style::default().fg(Color::Green)),
// Numbers.
"number" => Some(Style::default().fg(Color::Cyan)),
// Words: if they look like flags, colour them; else leave for default.
"word" => {
if let Ok(text) = node.utf8_text(src.as_bytes()) {
if text.starts_with('-') {
Some(Style::default().fg(Color::Yellow))
} else {
None
}
} else {
None
}
}
// Only color operator tokens when they are actual operator nodes.
_ if !node.is_named() && matches!(kind, "&&" | "||" | "|" | ";" | ">" | "<") => {
Some(Style::default().fg(Color::Gray))
}
_ => None,
};
if let Some(style) = style {
let (start, end) = (node.start_byte(), node.end_byte());
if start < end && end <= src.len() {
segs.push(Seg { start, end, style });
}
// If we styled a whole string node, skip its children to avoid
// coloring operator tokens inside strings.
if matches!(kind, "string" | "raw_string") {
continue;
}
}
for child in node.children(&mut cursor) {
stack.push(child);
}
}
// Note: We do NOT globally scan for operator characters; we rely on
// tree-sitter nodes above so operators inside strings are not colored.
if segs.is_empty() {
return Line::from(src.to_string());
}
// Merge segments into a sequence of Spans in order, preserving gaps.
segs.sort_by_key(|s| s.start);
let mut spans: Vec<Span<'static>> = Vec::new();
let mut pos = 0usize;
for Seg { start, end, style } in segs {
if start > pos {
spans.push(Span::raw(src[pos..start].to_string()));
}
let piece = &src[start..end];
spans.push(Span::styled(piece.to_string(), style));
pos = end;
}
if pos < src.len() {
spans.push(Span::raw(src[pos..].to_string()));
}
Line::from(spans)
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::style::Color;
#[test]
fn highlight_does_not_color_operators_inside_strings() {
// Example provided by user: regex pipes should remain inside a green string span,
// and not be treated as shell operators.
let cmd = r#"rg -n --no-ignore-vcs -S "TODO|FIXME|XXX|HACK|TBD|\bBUG\b|\bunimplemented!\(|\btodo!\(""#;
let line = highlight_shell_command_line(cmd);
// Reconstruct text
let reconstructed: String = line
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect::<Vec<_>>()
.join("");
assert_eq!(reconstructed, cmd);
// There should be no gray operator tokens, since all pipes are inside quotes.
let has_gray_ops = line.spans.iter().any(|s| {
s.style.fg == Some(Color::Gray) && (s.content.contains('|') || s.content.contains("||"))
});
assert!(
!has_gray_ops,
"found gray operator tokens inside quoted regex"
);
// There should be at least one green span that contains a pipe character from the regex.
let has_green_with_pipe = line
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Green) && s.content.contains('|'));
assert!(
has_green_with_pipe,
"expected quoted regex to be highlighted as a green string"
);
}
#[test]
fn highlight_colors_command_and_flags() {
let cmd = "rg -n --no-ignore-vcs foo";
let line = highlight_shell_command_line(cmd);
let text: String = line
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect();
assert_eq!(text, cmd);
// Find first token 'rg' and ensure it's blue (command name)
let has_blue_cmd = line
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Blue) && s.content.contains("rg"));
assert!(has_blue_cmd, "expected command name to be blue");
// Flags should be yellow
let has_short_flag = line
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Yellow) && s.content.contains("-n"));
let has_long_flag = line
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Yellow) && s.content.contains("--no-ignore-vcs"));
assert!(
has_short_flag && has_long_flag,
"expected flags to be yellow"
);
}
#[test]
fn highlight_colors_numbers() {
let cmd = "echo 123 456";
let line = highlight_shell_command_line(cmd);
let text: String = line
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect();
assert_eq!(text, cmd);
let has_cyan_numbers = line.spans.iter().any(|s| {
s.style.fg == Some(Color::Cyan)
&& (s.content.contains("123") || s.content.contains("456"))
});
assert!(has_cyan_numbers, "expected numbers to be cyan");
}
#[test]
fn highlight_colors_operators_outside_strings() {
let cmd = "echo a | grep b && true;";
let line = highlight_shell_command_line(cmd);
let text: String = line
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect();
assert_eq!(text, cmd);
// Operators outside strings should be gray
let has_gray_ops = line.spans.iter().any(|s| {
s.style.fg == Some(Color::Gray)
&& (s.content.contains("|") || s.content.contains("&&") || s.content.contains(";"))
});
assert!(
has_gray_ops,
"expected operators outside strings to be gray"
);
}
#[test]
fn highlight_handles_redirections() {
let cmd = "cat file > out && echo ok";
let line = highlight_shell_command_line(cmd);
let text: String = line
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect();
assert_eq!(text, cmd);
let has_gray_redirect = line
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Gray) && s.content.contains(">"));
assert!(has_gray_redirect, "expected '>' to be colored (gray)");
}
#[test]
fn highlight_multiple_quoted_strings() {
let cmd = "echo \"a|b\" 'c|d'";
let line = highlight_shell_command_line(cmd);
let text: String = line
.spans
.iter()
.map(|s| s.content.clone().into_owned())
.collect();
assert_eq!(text, cmd);
let green_spans_with_pipes = line
.spans
.iter()
.filter(|s| s.style.fg == Some(Color::Green) && s.content.contains("|"))
.count();
assert!(
green_spans_with_pipes >= 2,
"expected both quoted strings to be green with pipes"
);
let has_gray_pipes = line
.spans
.iter()
.any(|s| s.style.fg == Some(Color::Gray) && s.content.contains("|"));
assert!(
!has_gray_pipes,
"should not color '|' as operator inside quotes"
);
}
}

View File

@@ -206,10 +206,7 @@ impl TextArea {
match event {
KeyEvent {
code: KeyCode::Char(c),
// 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,
modifiers: KeyModifiers::NONE | KeyModifiers::SHIFT | KeyModifiers::ALT,
..
} => self.insert_str(&c.to_string()),
KeyEvent {
@@ -248,23 +245,6 @@ 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,
@@ -295,23 +275,6 @@ 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, ..
} => {
@@ -349,6 +312,20 @@ 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);
}

View File

@@ -79,6 +79,8 @@ pub(crate) struct ChatWidget<'a> {
current_stream: Option<StreamKind>,
stream_header_emitted: bool,
live_max_rows: u16,
/// Absolute path to the rollout file for the active session (if available).
rollout_path: Option<PathBuf>,
}
struct UserMessage {
@@ -110,22 +112,6 @@ 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(
@@ -223,6 +209,7 @@ impl ChatWidget<'_> {
current_stream: None,
stream_header_emitted: false,
live_max_rows: 3,
rollout_path: None,
}
}
@@ -299,6 +286,8 @@ impl ChatWidget<'_> {
EventMsg::SessionConfigured(event) => {
self.bottom_pane
.set_history_metadata(event.history_log_id, event.history_entry_count);
// Record rollout path for status reporting.
self.rollout_path = event.rollout_path.clone();
// Record session information at the top of the conversation.
self.add_to_history(HistoryCell::new_session_info(&self.config, event, true));
@@ -568,6 +557,7 @@ impl ChatWidget<'_> {
self.add_to_history(HistoryCell::new_status_output(
&self.config,
&self.token_usage,
self.rollout_path.as_ref(),
));
}
@@ -585,7 +575,18 @@ impl ChatWidget<'_> {
CancellationEvent::Ignored => {}
}
if self.bottom_pane.is_task_running() {
self.interrupt_running_task();
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();
CancellationEvent::Ignored
} else if self.bottom_pane.ctrl_c_quit_hint_visible() {
self.submit_op(Op::Shutdown);
@@ -596,10 +597,6 @@ 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()
}

View File

@@ -1,4 +0,0 @@
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);

View File

@@ -1,4 +1,3 @@
use crate::bash_highlight::highlight_shell_command_line;
use crate::exec_command::relativize_to_home;
use crate::exec_command::strip_bash_lc_and_escape;
use crate::slash_command::SlashCommand;
@@ -167,12 +166,7 @@ impl HistoryCell {
event: SessionConfiguredEvent,
is_first_event: bool,
) -> Self {
let SessionConfiguredEvent {
model,
session_id: _,
history_log_id: _,
history_entry_count: _,
} = event;
let SessionConfiguredEvent { model, .. } = event;
if is_first_event {
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
@@ -232,21 +226,11 @@ impl HistoryCell {
pub(crate) fn new_active_exec_command(command: Vec<String>) -> Self {
let command_escaped = strip_bash_lc_and_escape(&command);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from(vec!["command".magenta(), " running...".dim()]));
// Render the command with basic shell syntax highlighting.
let mut cmd_line = highlight_shell_command_line(&command_escaped);
// Prepend the prompt marker dimmed.
let spans = std::mem::take(&mut cmd_line.spans);
if spans.is_empty() {
lines.push(Line::from(format!("$ {command_escaped}")));
} else {
let mut new_spans = Vec::with_capacity(spans.len() + 1);
new_spans.push(Span::raw("$ ").dim());
new_spans.extend(spans);
lines.push(Line::from(new_spans));
}
lines.push(Line::from(""));
let lines: Vec<Line<'static>> = vec![
Line::from(vec!["command".magenta(), " running...".dim()]),
Line::from(format!("$ {command_escaped}")),
Line::from(""),
];
HistoryCell::ActiveExecCommand {
view: TextBlock::new(lines),
@@ -278,17 +262,7 @@ impl HistoryCell {
let src = if exit_code == 0 { stdout } else { stderr };
let cmdline = strip_bash_lc_and_escape(&command);
// Render the command with basic shell syntax highlighting.
let mut cmd_line = highlight_shell_command_line(&cmdline);
let spans = std::mem::take(&mut cmd_line.spans);
if spans.is_empty() {
lines.push(Line::from(format!("$ {cmdline}")));
} else {
let mut new_spans = Vec::with_capacity(spans.len() + 1);
new_spans.push(Span::raw("$ ").dim());
new_spans.extend(spans);
lines.push(Line::from(new_spans));
}
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());
@@ -471,7 +445,11 @@ impl HistoryCell {
}
}
pub(crate) fn new_status_output(config: &Config, usage: &TokenUsage) -> Self {
pub(crate) fn new_status_output(
config: &Config,
usage: &TokenUsage,
rollout_path: Option<&std::path::PathBuf>,
) -> Self {
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(Line::from("/status".magenta()));
@@ -480,6 +458,14 @@ impl HistoryCell {
lines.push(Line::from(vec![format!("{key}: ").bold(), value.into()]));
}
// Rollout file path (if available)
if let Some(p) = rollout_path {
lines.push(Line::from(vec![
"rollout: ".bold(),
p.display().to_string().into(),
]));
}
// Token usage
lines.push(Line::from(""));
lines.push(Line::from("token usage".bold()));

View File

@@ -13,6 +13,7 @@ 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;
@@ -22,12 +23,10 @@ use tracing_subscriber::prelude::*;
mod app;
mod app_event;
mod app_event_sender;
mod bash_highlight;
mod bottom_pane;
mod chatwidget;
mod citation_regex;
mod cli;
mod colors;
pub mod custom_terminal;
mod exec_command;
mod file_search;
@@ -38,8 +37,6 @@ 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;
@@ -207,6 +204,24 @@ 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

View File

@@ -22,9 +22,7 @@ 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?;
if !usage.is_zero() {
println!("{}", codex_core::protocol::FinalOutput::from(usage));
}
println!("{}", codex_core::protocol::FinalOutput::from(usage));
Ok(())
})
}

View File

@@ -1,316 +0,0 @@
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);
}
}
}
}

View File

@@ -1,3 +0,0 @@
mod auth;
pub mod onboarding_screen;
mod welcome;

View File

@@ -1,157 +0,0 @@
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);
}
}
}
}

View File

@@ -1,23 +0,0 @@
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);
}
}

View File

@@ -1,84 +0,0 @@
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
}
}