Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 58 additions & 14 deletions codex-rs/core/src/message_history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ use crate::config_types::HistoryPersistence;
use std::os::unix::fs::OpenOptionsExt;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;

/// Filename that stores the message history inside `~/.codex`.
const HISTORY_FILENAME: &str = "history.jsonl";
Expand Down Expand Up @@ -125,18 +127,17 @@ pub(crate) async fn append_entry(text: &str, session_id: &Uuid, config: &Config)
/// times if the lock is currently held by another process. This prevents a
/// potential indefinite wait while still giving other writers some time to
/// finish their operation.
#[cfg(unix)]
async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> {
use tokio::time::sleep;

for _ in 0..MAX_RETRIES {
match file.try_lock() {
match try_flock_exclusive(file) {
Ok(()) => return Ok(()),
Err(e) => match e {
std::fs::TryLockError::WouldBlock => {
sleep(RETRY_SLEEP).await;
}
other => return Err(other.into()),
},
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
sleep(RETRY_SLEEP).await;
}
Err(e) => return Err(e),
}
}

Expand All @@ -146,6 +147,12 @@ async fn acquire_exclusive_lock_with_retry(file: &File) -> Result<()> {
))
}

#[cfg(not(unix))]
async fn acquire_exclusive_lock_with_retry(_file: &File) -> Result<()> {
// On non-Unix, skip locking; appends are still atomic with O_APPEND.
Ok(())
}

/// Asynchronously fetch the history file's *identifier* (inode on Unix) and
/// the current number of entries by counting newline characters.
pub(crate) async fn history_metadata(config: &Config) -> (u64, usize) {
Expand Down Expand Up @@ -261,14 +268,12 @@ pub(crate) fn lookup(log_id: u64, offset: usize, config: &Config) -> Option<Hist
#[cfg(unix)]
fn acquire_shared_lock_with_retry(file: &File) -> Result<()> {
for _ in 0..MAX_RETRIES {
match file.try_lock_shared() {
match try_flock_shared(file) {
Ok(()) => return Ok(()),
Err(e) => match e {
std::fs::TryLockError::WouldBlock => {
std::thread::sleep(RETRY_SLEEP);
}
other => return Err(other.into()),
},
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
std::thread::sleep(RETRY_SLEEP);
}
Err(e) => return Err(e),
}
}

Expand All @@ -278,6 +283,45 @@ fn acquire_shared_lock_with_retry(file: &File) -> Result<()> {
))
}

#[cfg(not(unix))]
fn acquire_shared_lock_with_retry(_file: &File) -> Result<()> {
Ok(())
}

#[cfg(unix)]
fn try_flock_exclusive(file: &File) -> Result<()> {
let fd = file.as_raw_fd();
let rc = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) };
if rc == 0 {
Ok(())
} else {
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(code) if code == libc::EWOULDBLOCK || code == libc::EAGAIN => Err(
std::io::Error::new(std::io::ErrorKind::WouldBlock, "lock would block"),
),
_ => Err(err),
}
}
}

#[cfg(unix)]
fn try_flock_shared(file: &File) -> Result<()> {
let fd = file.as_raw_fd();
let rc = unsafe { libc::flock(fd, libc::LOCK_SH | libc::LOCK_NB) };
if rc == 0 {
Ok(())
} else {
let err = std::io::Error::last_os_error();
match err.raw_os_error() {
Some(code) if code == libc::EWOULDBLOCK || code == libc::EAGAIN => Err(
std::io::Error::new(std::io::ErrorKind::WouldBlock, "lock would block"),
),
_ => Err(err),
}
}
}

/// On Unix systems ensure the file permissions are `0o600` (rw-------). If the
/// permissions cannot be changed the error is propagated to the caller.
#[cfg(unix)]
Expand Down
61 changes: 61 additions & 0 deletions codex-rs/tui/src/emoji_width.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
use std::io::Write;

use ratatui::crossterm::execute;
use ratatui::crossterm::style::Print;
use ratatui::crossterm::terminal::Clear;
use ratatui::crossterm::terminal::ClearType;
use unicode_width::UnicodeWidthStr;

static EMOJI_OK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();

/// Returns true if common emoji we use advance the cursor by the same number of
/// columns as reported by `unicode-width` on this terminal. The value is cached.
pub fn emojis_render_as_expected() -> bool {
// Optimistic default: render emoji unless we have measured otherwise.
EMOJI_OK.get().copied().unwrap_or(true)
}

/// Ensure that emoji width has been probed and cached. Call during TUI init.
pub fn ensure_probed() {
let _ = EMOJI_OK.get_or_init(detect);
}

/// Run a small runtime probe by printing a few glyphs at (0,0) and reading the
/// cursor position. If any measured width differs from `unicode-width`, we
/// conclude emoji rendering is unreliable and the UI should fall back to ASCII.
pub fn detect() -> bool {
// Only probe a small curated set that we actually use in the UI.
const TESTS: &[&str] = &["📂", "📖", "🔎", "🧪", "⚡", "⚙︎", "✏︎", "✓", "✗", "🖐"];

let mut out = std::io::stdout();
// Best effort: on error, default to false (use ASCII) to avoid broken layout.
let _ = execute!(out, Clear(ClearType::All));
for s in TESTS {
let expected = s.width();
// Move to origin, print, flush, read cursor position.
if execute!(out, ratatui::crossterm::cursor::MoveTo(0, 0), Print(*s)).is_err() {
return false;
}
if out.flush().is_err() {
return false;
}
let Ok((x, _y)) = ratatui::crossterm::cursor::position() else {
return false;
};
if x as usize != expected {
// Clear the line before returning.
let _ = execute!(
out,
ratatui::crossterm::cursor::MoveTo(0, 0),
Clear(ClearType::CurrentLine)
);
return false;
}
}
let _ = execute!(
out,
ratatui::crossterm::cursor::MoveTo(0, 0),
Clear(ClearType::All)
);
true
}
74 changes: 50 additions & 24 deletions codex-rs/tui/src/history_cell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -244,19 +244,22 @@ fn new_parsed_command(
let mut lines: Vec<Line> = Vec::new();
match output {
None => {
let mut spans = vec!["⚙︎ Working".magenta().bold()];
let mut spans = vec![crate::icons::working().magenta().bold()];
if let Some(st) = start_time {
let dur = exec_duration(st);
spans.push(format!(" • {dur}").dim());
}
lines.push(Line::from(spans));
}
Some(o) if o.exit_code == 0 => {
lines.push(Line::from(vec!["✓".green(), " Completed".into()]));
lines.push(Line::from(vec![
crate::icons::completed_label().green(),
" Completed".into(),
]));
}
Some(o) => {
lines.push(Line::from(vec![
"✗".red(),
crate::icons::failed_label().red(),
format!(" Failed (exit {})", o.exit_code).into(),
]));
}
Expand All @@ -282,22 +285,22 @@ fn new_parsed_command(

for (i, parsed) in parsed_commands.iter().enumerate() {
let text = match parsed {
ParsedCommand::Read { name, .. } => format!("📖 {name}"),
ParsedCommand::Read { name, .. } => format!("{} {name}", crate::icons::book()),
ParsedCommand::ListFiles { cmd, path } => match path {
Some(p) => format!("📂 {p}"),
None => format!("📂 {cmd}"),
Some(p) => format!("{} {p}", crate::icons::folder()),
None => format!("{} {cmd}", crate::icons::folder()),
},
ParsedCommand::Search { query, path, cmd } => match (query, path) {
(Some(q), Some(p)) => format!("🔎 {q} in {p}"),
(Some(q), None) => format!("🔎 {q}"),
(None, Some(p)) => format!("🔎 {p}"),
(None, None) => format!("🔎 {cmd}"),
(Some(q), Some(p)) => format!("{} {q} in {p}", crate::icons::search()),
(Some(q), None) => format!("{} {q}", crate::icons::search()),
(None, Some(p)) => format!("{} {p}", crate::icons::search()),
(None, None) => format!("{} {cmd}", crate::icons::search()),
},
ParsedCommand::Format { .. } => "✨ Formatting".to_string(),
ParsedCommand::Test { cmd } => format!("🧪 {cmd}"),
ParsedCommand::Lint { cmd, .. } => format!("🧹 {cmd}"),
ParsedCommand::Unknown { cmd } => format!("⌨️ {cmd}"),
ParsedCommand::Noop { cmd } => format!("🔄 {cmd}"),
ParsedCommand::Format { .. } => format!("{} Formatting", crate::icons::formatting()),
ParsedCommand::Test { cmd } => format!("{} {cmd}", crate::icons::test()),
ParsedCommand::Lint { cmd, .. } => format!("{} {cmd}", crate::icons::lint()),
ParsedCommand::Unknown { cmd } => format!("{} {cmd}", crate::icons::keyboard_cmd()),
ParsedCommand::Noop { cmd } => format!("{} {cmd}", crate::icons::noop()),
};

let first_prefix = if i == 0 { " └ " } else { " " };
Expand Down Expand Up @@ -325,7 +328,7 @@ fn new_exec_command_generic(
let command_escaped = strip_bash_lc_and_escape(command);
let mut cmd_lines = command_escaped.lines();
if let Some(first) = cmd_lines.next() {
let mut spans: Vec<Span> = vec!["⚡ Running".magenta()];
let mut spans: Vec<Span> = vec![crate::icons::running().magenta()];
if let Some(st) = start_time {
let dur = exec_duration(st);
spans.push(format!(" • {dur}").dim());
Expand Down Expand Up @@ -514,7 +517,11 @@ pub(crate) fn new_status_output(
};

// 📂 Workspace
lines.push(Line::from(vec!["📂 ".into(), "Workspace".bold()]));
lines.push(Line::from(vec![
crate::icons::workspace().into(),
" ".into(),
"Workspace".bold(),
]));
// Path (home-relative, e.g., ~/code/project)
let cwd_str = match relativize_to_home(&config.cwd) {
Some(rel) if !rel.as_os_str().is_empty() => format!("~/{}", rel.display()),
Expand Down Expand Up @@ -545,7 +552,11 @@ pub(crate) fn new_status_output(
if let Ok(auth) = try_read_auth_json(&auth_file)
&& let Some(tokens) = auth.tokens.clone()
{
lines.push(Line::from(vec!["👤 ".into(), "Account".bold()]));
lines.push(Line::from(vec![
crate::icons::account().into(),
" ".into(),
"Account".bold(),
]));
lines.push(Line::from(" • Signed in with ChatGPT"));

let info = tokens.id_token;
Expand All @@ -572,7 +583,11 @@ pub(crate) fn new_status_output(
}

// 🧠 Model
lines.push(Line::from(vec!["🧠 ".into(), "Model".bold()]));
lines.push(Line::from(vec![
crate::icons::model().into(),
" ".into(),
"Model".bold(),
]));
lines.push(Line::from(vec![
" • Name: ".into(),
config.model.clone().into(),
Expand Down Expand Up @@ -601,7 +616,11 @@ pub(crate) fn new_status_output(
lines.push(Line::from(""));

// 📊 Token Usage
lines.push(Line::from(vec!["📊 ".into(), "Token Usage".bold()]));
lines.push(Line::from(vec![
crate::icons::token_usage().into(),
" ".into(),
"Token Usage".bold(),
]));
if let Some(session_id) = session_id {
lines.push(Line::from(vec![
" • Session ID: ".into(),
Expand Down Expand Up @@ -715,7 +734,14 @@ pub(crate) fn new_mcp_tools_output(
}

pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
let lines: Vec<Line<'static>> = vec![vec!["🖐 ".red().bold(), message.into()].into(), "".into()];
let lines: Vec<Line<'static>> = vec![
vec![
format!("{} ", crate::icons::wave_error()).red().bold(),
message.into(),
]
.into(),
"".into(),
];
PlainHistoryCell { lines }
}

Expand All @@ -740,7 +766,7 @@ pub(crate) fn new_plan_update(update: UpdatePlanArgs) -> PlainHistoryCell {
let empty = width.saturating_sub(filled);

let mut header: Vec<Span> = Vec::new();
header.push(Span::raw("📋"));
header.push(Span::raw(crate::icons::clipboard()));
header.push(Span::styled(
" Update plan",
Style::default().add_modifier(Modifier::BOLD).magenta(),
Expand Down Expand Up @@ -830,12 +856,12 @@ pub(crate) fn new_patch_event(
PatchEventType::ApprovalRequest => "proposed patch",
PatchEventType::ApplyBegin {
auto_approved: true,
} => "✏️ Applying patch",
} => crate::icons::apply_patch(),
PatchEventType::ApplyBegin {
auto_approved: false,
} => {
let lines: Vec<Line<'static>> = vec![
Line::from("✏️ Applying patch".magenta().bold()),
Line::from(crate::icons::apply_patch().magenta().bold()),
Line::from(""),
];
return PlainHistoryCell { lines };
Expand Down
Loading