mirror of
https://github.com/openai/codex.git
synced 2026-11-08 00:36:31 +00:00
Fix TUI layout for halfwidth Japanese sound marks (#35962)
## What changed - Use Ratatui-compatible terminal widths throughout TUI layout, preserving full-width counters for long lines. - Wrap and truncate on grapheme boundaries so halfwidth kana sound marks and emoji sequences stay intact. - Apply the corrected sizing to text input, selection popups, status and history cells, Markdown, hyperlinks, and scrollable cloud diffs. ## Testing - Add focused unit and snapshot coverage for wrapping, truncation, cursor and mask alignment, popup columns, status output, session headers, Markdown tables, process output, and scrollable diffs. GitOrigin-RevId: 294ec0bcff743c6bb16d55ec26308524ce741879
This commit is contained in:
1 parent
cef3910ea4
commit
3725f02cf3
38 files changed
+1335
-245
No files matched your search
@@ -4,9 +4,11 @@ use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::plain_lines;
|
||||
use crate::history_cell::with_border_with_inner_width;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::line_truncation::line_width;
|
||||
use crate::token_usage::TokenUsage;
|
||||
use crate::token_usage::TokenUsageInfo;
|
||||
use crate::version::CODEX_CLI_VERSION;
|
||||
use crate::width::display_width;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use codex_app_server_protocol::AskForApproval;
|
||||
@@ -26,12 +28,10 @@ use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::PathBuf;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use url::Url;
|
||||
|
||||
use super::account::StatusAccountDisplay;
|
||||
use super::format::FieldFormatter;
|
||||
use super::format::line_display_width;
|
||||
use super::format::push_label;
|
||||
use super::format::truncate_line_to_width;
|
||||
use super::helpers::compose_account_display;
|
||||
@@ -482,7 +482,7 @@ impl StatusHistoryCell {
|
||||
];
|
||||
// On narrow terminals, keep the percentage visible rather than
|
||||
// letting the fixed-width progress bar crowd out the reset time.
|
||||
let value_spans = if line_display_width(&Line::from(full_value_spans.clone()))
|
||||
let value_spans = if line_width(&Line::from(full_value_spans.clone()))
|
||||
<= formatter.value_width(available_inner_width)
|
||||
{
|
||||
full_value_spans
|
||||
@@ -498,9 +498,7 @@ impl StatusHistoryCell {
|
||||
inline_spans.push(Span::from(" ").dim());
|
||||
inline_spans.push(resets_span.clone());
|
||||
|
||||
if line_display_width(&Line::from(inline_spans.clone()))
|
||||
<= available_inner_width
|
||||
{
|
||||
if line_width(&Line::from(inline_spans.clone())) <= available_inner_width {
|
||||
lines.push(Line::from(inline_spans));
|
||||
} else {
|
||||
lines.push(base_line);
|
||||
@@ -863,7 +861,7 @@ impl HistoryCell for StatusHistoryCell {
|
||||
|
||||
lines.extend(self.rate_limit_lines(&rate_limit_state, available_inner_width, &formatter));
|
||||
|
||||
let content_width = lines.iter().map(line_display_width).max().unwrap_or(0);
|
||||
let content_width = lines.iter().map(line_width).max().unwrap_or(0);
|
||||
let inner_width = content_width.min(available_inner_width);
|
||||
let truncated_lines: Vec<Line<'static>> = lines
|
||||
.into_iter()
|
||||
@@ -891,10 +889,10 @@ impl HistoryCell for StatusHistoryCell {
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>();
|
||||
if let Some(start_byte) = visible.find(CHATGPT_USAGE_URL) {
|
||||
let start = visible[..start_byte].width();
|
||||
let start = display_width(&visible[..start_byte]);
|
||||
line.hyperlinks
|
||||
.push(crate::terminal_hyperlinks::TerminalHyperlink::web(
|
||||
start..start + CHATGPT_USAGE_URL.width(),
|
||||
start..start + display_width(CHATGPT_USAGE_URL),
|
||||
CHATGPT_USAGE_URL.to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
use ratatui::prelude::*;
|
||||
use ratatui::style::Stylize;
|
||||
use std::collections::BTreeSet;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
use crate::width::display_width;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct FieldFormatter {
|
||||
@@ -21,10 +22,10 @@ impl FieldFormatter {
|
||||
{
|
||||
let label_width = labels
|
||||
.into_iter()
|
||||
.map(|label| UnicodeWidthStr::width(label.as_ref()))
|
||||
.map(|label| display_width(label.as_ref()))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let indent_width = UnicodeWidthStr::width(Self::INDENT);
|
||||
let indent_width = display_width(Self::INDENT);
|
||||
let value_offset = indent_width + label_width + 1 + 3;
|
||||
|
||||
Self {
|
||||
@@ -72,7 +73,7 @@ impl FieldFormatter {
|
||||
buf.push_str(label);
|
||||
buf.push(':');
|
||||
|
||||
let label_width = UnicodeWidthStr::width(label);
|
||||
let label_width = display_width(label);
|
||||
let padding = 3 + self.label_width.saturating_sub(label_width);
|
||||
for _ in 0..padding {
|
||||
buf.push(' ');
|
||||
@@ -92,12 +93,6 @@ pub(crate) fn push_label(labels: &mut Vec<String>, seen: &mut BTreeSet<String>,
|
||||
labels.push(owned);
|
||||
}
|
||||
|
||||
pub(crate) fn line_display_width(line: &Line<'static>) -> usize {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> Line<'static> {
|
||||
if max_width == 0 {
|
||||
return Line::from(Vec::<Span<'static>>::new());
|
||||
@@ -109,7 +104,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
for span in line.spans {
|
||||
let text = span.content.into_owned();
|
||||
let style = span.style;
|
||||
let span_width = UnicodeWidthStr::width(text.as_str());
|
||||
let span_width = display_width(text.as_str());
|
||||
|
||||
if span_width == 0 {
|
||||
spans_out.push(Span::styled(text, style));
|
||||
@@ -127,13 +122,13 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
}
|
||||
|
||||
let mut truncated = String::new();
|
||||
for ch in text.chars() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + ch_width > max_width {
|
||||
for grapheme in text.graphemes(/*is_extended*/ true) {
|
||||
let grapheme_width = display_width(grapheme);
|
||||
if used + grapheme_width > max_width {
|
||||
break;
|
||||
}
|
||||
truncated.push(ch);
|
||||
used += ch_width;
|
||||
truncated.push_str(grapheme);
|
||||
used += grapheme_width;
|
||||
}
|
||||
|
||||
if !truncated.is_empty() {
|
||||
|
||||
@@ -2,13 +2,13 @@ use crate::exec_command::relativize_to_home;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::status::StatusAccountDisplay;
|
||||
use crate::text_formatting;
|
||||
use crate::width::display_width;
|
||||
use chrono::DateTime;
|
||||
use chrono::Local;
|
||||
use codex_protocol::account::PlanType;
|
||||
use codex_utils_path_uri::PathConvention;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use std::path::Path;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
fn normalize_agents_display_path(path: &Path) -> String {
|
||||
dunce::simplified(path).display().to_string()
|
||||
@@ -164,7 +164,7 @@ pub(crate) fn format_directory_display(directory: &Path, max_width: Option<usize
|
||||
if max_width == 0 {
|
||||
return String::new();
|
||||
}
|
||||
if UnicodeWidthStr::width(formatted.as_str()) > max_width {
|
||||
if display_width(&formatted) > max_width {
|
||||
return text_formatting::center_truncate_path(&formatted, max_width);
|
||||
}
|
||||
}
|
||||
@@ -233,6 +233,15 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_directory_display_truncates_halfwidth_sound_marks() {
|
||||
let directory = Path::new("workspace").join("ガ").join("project");
|
||||
let max_width = display_width(directory.to_string_lossy().as_ref()) - 1;
|
||||
let formatted = format_directory_display(&directory, Some(max_width));
|
||||
|
||||
insta::assert_snapshot!(formatted.replace('\\', "/"), @"workspace/…/project");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn compose_agents_summary_includes_global_agents_path() {
|
||||
let codex_home = TempDir::new().expect("temp codex home");
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
---
|
||||
source: tui/src/status/tests.rs
|
||||
expression: sanitized
|
||||
---
|
||||
/status
|
||||
|
||||
╭────────────────────────────────────────╮
|
||||
│ >_ OpenAI Codex (v0.0.0) │
|
||||
│ │
|
||||
│ Visit │
|
||||
│ https://chatgpt.com/codex/settings/usa │
|
||||
│ for up-to-date │
|
||||
│ information on rate limits and credits │
|
||||
│ │
|
||||
│ Model: ガパガパガパガ │
|
||||
│ Directory: [[workspace]] │
|
||||
│ Permissions: Custom (workspa │
|
||||
│ Agents.md: <none> │
|
||||
│ Account: ガパガパガパ@ex │
|
||||
│ Thread name: ガパガパガパガ │
|
||||
│ Collaboration mode: ガパ collaborat │
|
||||
│ │
|
||||
│ Limits: data not availa │
|
||||
╰────────────────────────────────────────╯
|
||||
@@ -1525,6 +1525,42 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_truncates_halfwidth_kana_in_narrow_terminal() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
let mut config = test_config(&temp_home).await;
|
||||
set_workspace_cwd(&mut config, test_path_buf("/workspace/tests").abs());
|
||||
|
||||
let account = StatusAccountDisplay::ChatGpt {
|
||||
email: Some("ガパガパガパ@example.com".to_string()),
|
||||
plan: Some("ガパ plan".to_string()),
|
||||
};
|
||||
let usage = TokenUsage::default();
|
||||
let now = chrono::Local
|
||||
.with_ymd_and_hms(2024, 1, 2, 3, 4, 5)
|
||||
.single()
|
||||
.expect("timestamp");
|
||||
let composite = new_status_output(
|
||||
&config,
|
||||
Some(&account),
|
||||
/*token_info*/ None,
|
||||
&usage,
|
||||
&None,
|
||||
Some("ガパガパガパガパ thread".to_string()),
|
||||
/*forked_from*/ None,
|
||||
/*rate_limits*/ None,
|
||||
/*plan_type*/ None,
|
||||
now,
|
||||
"ガパガパガパガパ-model",
|
||||
Some("ガパ collaboration mode"),
|
||||
/*reasoning_effort_override*/ None,
|
||||
);
|
||||
let rendered_lines = render_lines(&composite.display_lines(/*width*/ 42));
|
||||
let sanitized = sanitize_directory(rendered_lines).join("\n");
|
||||
|
||||
assert_snapshot!(sanitized);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_snapshot_shows_missing_limits_message() {
|
||||
let temp_home = TempDir::new().expect("temp home");
|
||||
|
||||
Reference in new issue
Block a user