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:
Charlie Marsh authored and copyberry committed 2026-07-29 14:01:31 +00:00
1 parent cef3910ea4
commit 3725f02cf3
38 files changed
+1335 -245

No files matched your search

+7 -9
View File
@@ -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(),
));
}
+12 -17
View File
@@ -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() {
+11 -2
View File
@@ -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");
@@ -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 │
╰────────────────────────────────────────╯
+36
View File
@@ -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");