mirror of
https://github.com/openai/codex.git
synced 2026-02-02 15:03:38 +00:00
Compare commits
2 Commits
oss
...
fix-unicod
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95c5b9ef6b | ||
|
|
c6a470f573 |
@@ -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";
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
61
codex-rs/tui/src/emoji_width.rs
Normal file
61
codex-rs/tui/src/emoji_width.rs
Normal file
@@ -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
|
||||
}
|
||||
@@ -244,7 +244,7 @@ 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());
|
||||
@@ -252,11 +252,14 @@ fn new_parsed_command(
|
||||
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(),
|
||||
]));
|
||||
}
|
||||
@@ -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 { " " };
|
||||
@@ -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());
|
||||
@@ -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()),
|
||||
@@ -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;
|
||||
@@ -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(),
|
||||
@@ -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(),
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
@@ -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 };
|
||||
|
||||
156
codex-rs/tui/src/icons.rs
Normal file
156
codex-rs/tui/src/icons.rs
Normal file
@@ -0,0 +1,156 @@
|
||||
/// Icons used throughout the TUI. We fall back to ASCII-safe variants
|
||||
/// when runtime detection shows emoji cell widths are inconsistent with
|
||||
/// what we use for wrapping.
|
||||
use crate::emoji_width::emojis_render_as_expected;
|
||||
|
||||
pub fn running() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"⚡ Running"
|
||||
} else {
|
||||
"> Running"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn working() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"⚙︎ Working"
|
||||
} else {
|
||||
"* Working"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn completed_label() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✓"
|
||||
} else {
|
||||
"OK"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn failed_label() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✗"
|
||||
} else {
|
||||
"X"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn folder() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📂"
|
||||
} else {
|
||||
"[dir]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn book() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📖"
|
||||
} else {
|
||||
"[read]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🔎"
|
||||
} else {
|
||||
"[find]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn formatting() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✨"
|
||||
} else {
|
||||
"[fmt]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn test() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🧪"
|
||||
} else {
|
||||
"[test]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn lint() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🧹"
|
||||
} else {
|
||||
"[lint]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn keyboard_cmd() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"⌨︎"
|
||||
} else {
|
||||
"[cmd]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn noop() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🔄"
|
||||
} else {
|
||||
"[noop]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clipboard() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📋"
|
||||
} else {
|
||||
"[plan]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_patch() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"✏︎ Applying patch"
|
||||
} else {
|
||||
"Applying patch"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn workspace() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📂"
|
||||
} else {
|
||||
"[dir]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn account() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"👤"
|
||||
} else {
|
||||
"[acct]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn model() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🧠"
|
||||
} else {
|
||||
"[model]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn token_usage() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"📊"
|
||||
} else {
|
||||
"[tokens]"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn wave_error() -> &'static str {
|
||||
if emojis_render_as_expected() {
|
||||
"🖐"
|
||||
} else {
|
||||
"[!]"
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,12 @@ mod cli;
|
||||
mod common;
|
||||
pub mod custom_terminal;
|
||||
mod diff_render;
|
||||
mod emoji_width;
|
||||
mod exec_command;
|
||||
mod file_search;
|
||||
mod get_git_diff;
|
||||
mod history_cell;
|
||||
mod icons;
|
||||
pub mod insert_history;
|
||||
pub mod live_wrap;
|
||||
mod markdown;
|
||||
|
||||
@@ -27,6 +27,7 @@ use ratatui::text::Line;
|
||||
|
||||
use crate::custom_terminal;
|
||||
use crate::custom_terminal::Terminal as CustomTerminal;
|
||||
use crate::emoji_width;
|
||||
use tokio::select;
|
||||
use tokio_stream::Stream;
|
||||
|
||||
@@ -74,6 +75,10 @@ pub fn init() -> Result<Terminal> {
|
||||
// Clear screen and move cursor to top-left before drawing UI
|
||||
execute!(stdout(), Clear(ClearType::All), MoveTo(0, 0))?;
|
||||
|
||||
// Proactively probe emoji widths so downstream rendering can pick icons
|
||||
// safely without doing terminal mutations mid-render.
|
||||
emoji_width::ensure_probed();
|
||||
|
||||
let backend = CrosstermBackend::new(stdout());
|
||||
let tui = CustomTerminal::with_options(backend)?;
|
||||
Ok(tui)
|
||||
|
||||
Reference in New Issue
Block a user