mirror of
https://github.com/openai/codex.git
synced 2026-04-17 04:51:54 +03:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fde2b273d1 |
2
codex-rs/Cargo.lock
generated
2
codex-rs/Cargo.lock
generated
@@ -895,8 +895,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tree-sitter",
|
||||
"tree-sitter-bash",
|
||||
"tui-input",
|
||||
"tui-markdown",
|
||||
"unicode-segmentation",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -908,6 +908,7 @@ mod tests {
|
||||
model: "codex-mini-latest".into(),
|
||||
history_log_id: 42,
|
||||
history_entry_count: 3,
|
||||
rollout_path: None,
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 { .. } => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +48,4 @@ pub(crate) enum AppEvent {
|
||||
},
|
||||
|
||||
InsertHistory(Vec<Line<'static>>),
|
||||
|
||||
/// Onboarding: result of login_with_chatgpt.
|
||||
OnboardingAuthComplete(Result<(), String>),
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// Best‑effort 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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()));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mod auth;
|
||||
pub mod onboarding_screen;
|
||||
mod welcome;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user