Compare commits

...

2 Commits

Author SHA1 Message Date
easong-openai
95c5b9ef6b merge 2025-08-22 16:57:24 -07:00
easong-openai
c6a470f573 initial unicode fix for apple terminal 2025-08-20 14:41:10 -07:00
6 changed files with 332 additions and 38 deletions

View File

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

View 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
}

View File

@@ -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
View 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 {
"[!]"
}
}

View File

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

View File

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