initial unicode fix for apple terminal

This commit is contained in:
easong-openai
2025-08-20 14:41:10 -07:00
parent 97f995a749
commit c6a470f573
5 changed files with 268 additions and 24 deletions

View File

@@ -0,0 +1,55 @@
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 {
*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

@@ -243,7 +243,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());
@@ -251,11 +251,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(),
]));
}
@@ -281,22 +284,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 { " " };
@@ -324,7 +327,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());
@@ -513,7 +516,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()),
@@ -550,7 +557,11 @@ pub(crate) fn new_status_output(
let auth_file = get_auth_file(&config.codex_home);
if let Ok(auth) = try_read_auth_json(&auth_file) {
if 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;
@@ -578,7 +589,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(),
@@ -607,7 +622,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(),
]));
// Input: <input> [+ <cached> cached]
let mut input_line_spans: Vec<Span<'static>> = vec![
" • Input: ".into(),
@@ -635,7 +654,14 @@ pub(crate) fn new_status_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 }
}
@@ -660,7 +686,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(),
@@ -750,12 +776,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

@@ -17,6 +17,7 @@ use ratatui::crossterm::terminal::disable_raw_mode;
use ratatui::crossterm::terminal::enable_raw_mode;
use crate::custom_terminal::Terminal;
use crate::emoji_width;
/// A type alias for the terminal type used in this application
pub type Tui = Terminal<CrosstermBackend<Stdout>>;
@@ -45,6 +46,10 @@ pub fn init(_config: &Config) -> Result<Tui> {
// 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.
let _ = emoji_width::emojis_render_as_expected();
let backend = CrosstermBackend::new(stdout());
let tui = Terminal::with_options(backend)?;
Ok(tui)