From 31d752849904cf47fc3b83666c20c1a9d6d291d0 Mon Sep 17 00:00:00 2001 From: jimyag Date: Wed, 9 Sep 2026 00:20:36 +0800 Subject: [PATCH 1/2] feat(tui): wrap model usage columns Keep model usage details visible when the terminal is narrower than the rendered model list. Calculate the final text-column width before rendering, wrap model cells and totals to multiple lines, and size rows so the existing table navigation can scroll through all content. Signed-off-by: jimyag --- src/tui.rs | 347 +++++++++++++++++++++++++++++++---------------- src/tui/tests.rs | 68 +++++++++- 2 files changed, 294 insertions(+), 121 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index a43edab..4bb7eff 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2485,6 +2485,57 @@ fn format_model_usage_shares( .join(", ") } +/// Wrap model shares to the available column width without dropping any text. +/// Model entries stay together when possible and are hard-wrapped only when a +/// single entry is wider than the column. +fn wrap_model_usage_text(text: &str, width: usize, style: Style) -> Text<'static> { + let width = width.max(1); + let mut lines = Vec::new(); + let mut current = String::new(); + + for entry in text.split(", ").filter(|entry| !entry.is_empty()) { + let candidate = if current.is_empty() { + entry.to_string() + } else { + format!("{current}, {entry}") + }; + + if candidate.chars().count() <= width { + current = candidate; + continue; + } + + if !current.is_empty() { + lines.push(std::mem::take(&mut current)); + } + + if entry.chars().count() <= width { + current = entry.to_string(); + continue; + } + + let mut entry_chars = entry.chars(); + while entry_chars.clone().count() > width { + lines.push(entry_chars.by_ref().take(width).collect()); + } + current = entry_chars.collect(); + } + + if !current.is_empty() { + lines.push(current); + } + if lines.is_empty() { + lines.push(String::new()); + } + + Text::from( + lines + .into_iter() + .map(|line| Line::styled(line, style)) + .collect::>(), + ) +} + #[allow(clippy::too_many_arguments)] fn draw_aggregate_stats_table( frame: &mut Frame, @@ -2530,39 +2581,6 @@ fn draw_aggregate_stats_table( !hidden.contains(c) }; - let mut header_cells = vec![ - Cell::new(""), - Cell::new(period_header), - Cell::new(Text::from("Cost").right_aligned()), - ]; - if show("cached") { - header_cells.push(Cell::new(Text::from("Cached Tks").right_aligned())); - } - if show("input") { - header_cells.push(Cell::new(Text::from("Inp Tks").right_aligned())); - } - if show("output") { - header_cells.push(Cell::new(Text::from("Outp Tks").right_aligned())); - } - if show("reason") { - header_cells.push(Cell::new(Text::from("Reason Tks").right_aligned())); - } - if show("convs") { - header_cells.push(Cell::new(Text::from("Convs").right_aligned())); - } - if show("tools") { - header_cells.push(Cell::new(Text::from("Tools").right_aligned())); - } - if show("apps") { - header_cells.push(Cell::new("Apps")); - } - if show("models") { - header_cells.push(Cell::new("Models")); - } - let header = Row::new(header_cells) - .style(Style::default().add_modifier(Modifier::BOLD)) - .height(1); - // Find best values for highlighting // TODO: Let's refactor this. @@ -2619,6 +2637,86 @@ fn draw_aggregate_stats_table( } } + // Resolve text column widths before building rows. This lets model cells + // wrap to the final width instead of being clipped after the table is laid + // out. + let mut max_apps_width = APPS_COL_MIN_WIDTH; + let mut max_models_width = MODELS_COL_MIN_WIDTH; + let mut width_total_models = BTreeMap::new(); + let mut width_total_model_stats = BTreeMap::new(); + let mut width_all_apps = std::collections::BTreeSet::new(); + for period in &visible_periods { + if !date_filter.is_empty() && !date_matches_buffer(period, date_filter) { + continue; + } + let period_stats = aggregate_stats + .get(period) + .expect("visible period key must exist in aggregate stats"); + let models = format_model_usage_shares(&period_stats.models, &period_stats.model_stats); + max_models_width = max_models_width.max(models.chars().count()); + for (model, count) in &period_stats.models { + *width_total_models.entry(model.clone()).or_insert(0) += count; + } + for (model, stats) in &period_stats.model_stats { + width_total_model_stats + .entry(model.clone()) + .or_insert_with(|| ModelStats::new(model.clone())) + .add_model_stats(stats); + } + + let mut apps_vec: Vec = period_stats.apps.keys().cloned().collect(); + apps_vec.sort(); + max_apps_width = max_apps_width.max(apps_vec.join(", ").chars().count()); + width_all_apps.extend(period_stats.apps.keys().cloned()); + } + + let width_all_apps_text = width_all_apps.into_iter().collect::>().join(", "); + let width_all_models_text = + format_model_usage_shares(&width_total_models, &width_total_model_stats); + let mut apps_column_width = max_apps_width + .max(width_all_apps_text.chars().count()) + .clamp(APPS_COL_MIN_WIDTH, APPS_COL_MAX_WIDTH); + let mut models_column_width = max_models_width + .max(width_all_models_text.chars().count()) + .clamp(MODELS_COL_MIN_WIDTH, MODELS_COL_MAX_WIDTH); + + let mut fixed_width = 1usize + period_width as usize + 10; + let mut column_count = 3usize; + for (column, width) in [ + ("cached", TOKEN_COL_WIDTH), + ("input", TOKEN_COL_WIDTH), + ("output", TOKEN_COL_WIDTH), + ("reason", TOKEN_COL_WIDTH), + ("convs", COUNT_COL_WIDTH), + ("tools", COUNT_COL_WIDTH), + ] { + if show(column) { + fixed_width += width as usize; + column_count += 1; + } + } + column_count += usize::from(show("apps")) + usize::from(show("models")); + let column_spacing = column_count.saturating_sub(1) * 2; + let available_text_width = (area.width as usize) + .saturating_sub(fixed_width) + .saturating_sub(column_spacing); + let desired_text_width = if show("apps") { apps_column_width } else { 0 } + + if show("models") { + models_column_width + } else { + 0 + }; + let mut overflow = desired_text_width.saturating_sub(available_text_width); + if show("apps") { + let shrink = overflow.min(apps_column_width.saturating_sub(APPS_COL_MIN_WIDTH)); + apps_column_width -= shrink; + overflow -= shrink; + } + if show("models") && overflow > 0 { + let shrink = overflow.min(models_column_width.saturating_sub(MODELS_COL_MIN_WIDTH)); + models_column_width -= shrink; + } + let mut rows = Vec::new(); let mut total_cost_cents: u64 = 0; let mut total_cached: u64 = 0; @@ -2630,8 +2728,6 @@ fn draw_aggregate_stats_table( let mut total_models = BTreeMap::new(); let mut total_model_stats = BTreeMap::new(); let mut all_apps = std::collections::BTreeSet::new(); - let mut max_apps_width = APPS_COL_MIN_WIDTH; - let mut max_models_width = MODELS_COL_MIN_WIDTH; for (i, period) in visible_periods.iter().enumerate() { let period_stats = aggregate_stats @@ -2660,12 +2756,10 @@ fn draw_aggregate_stats_table( .add_model_stats(stats); } let models = format_model_usage_shares(&period_stats.models, &period_stats.model_stats); - max_models_width = max_models_width.max(models.chars().count()); let mut apps_vec: Vec = period_stats.apps.keys().cloned().collect(); apps_vec.sort(); let apps = apps_vec.join(", "); - max_apps_width = max_apps_width.max(apps.chars().count()); all_apps.extend(period_stats.apps.keys().cloned()); // Check if this is an empty row @@ -2812,10 +2906,12 @@ fn draw_aggregate_stats_table( } .right_aligned(); - let models_cell = Line::from(Span::styled( - models, + let models_cell = wrap_model_usage_text( + &models, + models_column_width, Style::default().add_modifier(Modifier::DIM), - )); + ); + let row_height = models_cell.height().clamp(1, u16::MAX as usize) as u16; let apps_cell = Line::from(Span::styled( apps, @@ -2832,32 +2928,36 @@ fn draw_aggregate_stats_table( Line::from(Span::raw("")) }; - let mut row_cells = vec![arrow_cell, period_cell, cost_cell]; + let mut row_cells = vec![ + Cell::new(arrow_cell), + Cell::new(period_cell), + Cell::new(cost_cell), + ]; if show("cached") { - row_cells.push(cached_cell); + row_cells.push(Cell::new(cached_cell)); } if show("input") { - row_cells.push(input_cell); + row_cells.push(Cell::new(input_cell)); } if show("output") { - row_cells.push(output_cell); + row_cells.push(Cell::new(output_cell)); } if show("reason") { - row_cells.push(reasoning_cell); + row_cells.push(Cell::new(reasoning_cell)); } if show("convs") { - row_cells.push(conv_cell); + row_cells.push(Cell::new(conv_cell)); } if show("tools") { - row_cells.push(tool_cell); + row_cells.push(Cell::new(tool_cell)); } if show("apps") { - row_cells.push(apps_cell); + row_cells.push(Cell::new(apps_cell)); } if show("models") { - row_cells.push(models_cell); + row_cells.push(Cell::new(models_cell)); } - rows.push(Row::new(row_cells)); + rows.push(Row::new(row_cells).height(row_height)); } // Summarize models and apps from the same visible periods as the numeric totals. @@ -2867,49 +2967,48 @@ fn draw_aggregate_stats_table( .any(|model| is_model_estimated(model)); let all_apps_text = all_apps.into_iter().collect::>().join(", "); let all_models_text = format_model_usage_shares(&total_models, &total_model_stats); - let mut apps_column_width = max_apps_width - .max(all_apps_text.chars().count()) - .clamp(APPS_COL_MIN_WIDTH, APPS_COL_MAX_WIDTH); - let mut models_column_width = max_models_width - .max(all_models_text.chars().count()) - .clamp(MODELS_COL_MIN_WIDTH, MODELS_COL_MAX_WIDTH); - let mut fixed_width = 1usize + period_width as usize + 10; - let mut column_count = 3usize; - for (column, width) in [ - ("cached", TOKEN_COL_WIDTH), - ("input", TOKEN_COL_WIDTH), - ("output", TOKEN_COL_WIDTH), - ("reason", TOKEN_COL_WIDTH), - ("convs", COUNT_COL_WIDTH), - ("tools", COUNT_COL_WIDTH), - ] { - if show(column) { - fixed_width += width as usize; - column_count += 1; - } + let mut header_cells = vec![ + Cell::new(""), + Cell::new(period_header), + Cell::new(Text::from("Cost").right_aligned()), + ]; + if show("cached") { + header_cells.push(Cell::new(Text::from("Cached Tks").right_aligned())); + } + if show("input") { + header_cells.push(Cell::new(Text::from("Inp Tks").right_aligned())); + } + if show("output") { + header_cells.push(Cell::new(Text::from("Outp Tks").right_aligned())); + } + if show("reason") { + header_cells.push(Cell::new(Text::from("Reason Tks").right_aligned())); + } + if show("convs") { + header_cells.push(Cell::new(Text::from("Convs").right_aligned())); + } + if show("tools") { + header_cells.push(Cell::new(Text::from("Tools").right_aligned())); } - column_count += usize::from(show("apps")) + usize::from(show("models")); - let column_spacing = column_count.saturating_sub(1) * 2; - let available_text_width = (area.width as usize) - .saturating_sub(fixed_width) - .saturating_sub(column_spacing); - let desired_text_width = if show("apps") { apps_column_width } else { 0 } - + if show("models") { - models_column_width - } else { - 0 - }; - let mut overflow = desired_text_width.saturating_sub(available_text_width); if show("apps") { - let shrink = overflow.min(apps_column_width.saturating_sub(APPS_COL_MIN_WIDTH)); - apps_column_width -= shrink; - overflow -= shrink; + header_cells.push(Cell::new("Apps")); } - if show("models") && overflow > 0 { - let shrink = overflow.min(models_column_width.saturating_sub(MODELS_COL_MIN_WIDTH)); - models_column_width -= shrink; + if show("models") { + header_cells.push(Cell::new(wrap_model_usage_text( + "Models", + models_column_width, + Style::default().add_modifier(Modifier::BOLD), + ))); } + let header_height = if show("models") { + wrap_model_usage_text("Models", models_column_width, Style::default()).height() + } else { + 1 + }; + let header = Row::new(header_cells) + .style(Style::default().add_modifier(Modifier::BOLD)) + .height(header_height.clamp(1, u16::MAX as usize) as u16); // Add separator row before totals let token_sep = "─".repeat(TOKEN_COL_WIDTH as usize); @@ -2956,15 +3055,15 @@ fn draw_aggregate_stats_table( let tw = TOKEN_COL_WIDTH as usize; let mut totals_cells = vec![ // Arrow indicator for totals row when selected - if table_state.selected() == Some(rows.len()) { + Cell::new(if table_state.selected() == Some(rows.len()) { Line::from(Span::styled( "→", Style::default().fg(accent).add_modifier(Modifier::BOLD), )) } else { Line::from(Span::raw("")) - }, - Line::from(Span::styled( + }), + Cell::new(Line::from(Span::styled( match aggregate_view_mode { AggregateViewMode::Hourly => format!("Total ({}h)", visible_periods.len()), AggregateViewMode::Daily => format!("Total ({}d)", visible_periods.len()), @@ -2973,21 +3072,23 @@ fn draw_aggregate_stats_table( AggregateViewMode::Yearly => format!("Total ({}y)", visible_periods.len()), }, Style::default().add_modifier(Modifier::BOLD), - )), - Line::from(Span::styled( - format!( - "{}{total_cost:.prec$}", - format_options.currency_symbol, - prec = format_options.cost_decimal_places - ), - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - )) - .right_aligned(), + ))), + Cell::new( + Line::from(Span::styled( + format!( + "{}{total_cost:.prec$}", + format_options.currency_symbol, + prec = format_options.cost_decimal_places + ), + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD), + )) + .right_aligned(), + ), ]; if show("cached") { - totals_cells.push( + totals_cells.push(Cell::new( Line::from(Span::styled( format_number_fit(total_cached, format_options, tw), Style::default() @@ -2995,46 +3096,46 @@ fn draw_aggregate_stats_table( .add_modifier(Modifier::BOLD), )) .right_aligned(), - ); + )); } if show("input") { - totals_cells.push( + totals_cells.push(Cell::new( Line::from(Span::styled( format_number_fit(total_input, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), - ); + )); } if show("output") { - totals_cells.push( + totals_cells.push(Cell::new( Line::from(Span::styled( format_number_fit(total_output, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), - ); + )); } if show("reason") { - totals_cells.push( + totals_cells.push(Cell::new( Line::from(Span::styled( format_number_fit(total_reasoning, format_options, tw), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), - ); + )); } if show("convs") { - totals_cells.push( + totals_cells.push(Cell::new( Line::from(Span::styled( format_number(total_conversations, format_options), Style::default().add_modifier(Modifier::BOLD), )) .right_aligned(), - ); + )); } if show("tools") { - totals_cells.push( + totals_cells.push(Cell::new( Line::from(Span::styled( format_number(total_tool_calls, format_options), Style::default() @@ -3042,21 +3143,27 @@ fn draw_aggregate_stats_table( .add_modifier(Modifier::BOLD), )) .right_aligned(), - ); + )); } if show("apps") { - totals_cells.push(Line::from(Span::styled( + totals_cells.push(Cell::new(Line::from(Span::styled( all_apps_text, Style::default().add_modifier(Modifier::DIM), - ))); + )))); } if show("models") { - totals_cells.push(Line::from(Span::styled( - all_models_text, + totals_cells.push(Cell::new(wrap_model_usage_text( + &all_models_text, + models_column_width, Style::default().add_modifier(Modifier::DIM), ))); } - rows.push(Row::new(totals_cells)); + let total_row_height = if show("models") { + wrap_model_usage_text(&all_models_text, models_column_width, Style::default()).height() + } else { + 1 + }; + rows.push(Row::new(totals_cells).height(total_row_height.clamp(1, u16::MAX as usize) as u16)); // Save the row count before moving rows into the table let total_rows = rows.len(); diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 94a5910..175e615 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -10,7 +10,7 @@ use crate::tui::{ filter_analyzer_view_by_project, filtered_session_count, format_model_usage_shares, format_month_for_display, format_week_for_display, format_year_for_display, parse_accent, sessions_for_period, show_upload_error, show_upload_success, update_period_filters, - update_table_states, update_window_offsets, + update_table_states, update_window_offsets, wrap_model_usage_text, }; use crate::types::{ AgenticCodingToolStats, AnalyzerStatsView, Application, CompactDate, ConversationMessage, @@ -1115,6 +1115,72 @@ fn project_summary_and_filter_merge_tools_by_path() { assert_eq!(day.model_stats["gpt-5.6"].input_tokens, 200); } +#[test] +fn model_usage_text_wraps_without_dropping_entries() { + let wrapped = wrap_model_usage_text("model-a 60.0%, model-b 40.0%", 14, Color::Gray.into()); + + assert_eq!(wrapped.height(), 2); + assert_eq!(wrapped.lines[0].to_string(), "model-a 60.0%"); + assert_eq!(wrapped.lines[1].to_string(), "model-b 40.0%"); +} + +#[test] +fn aggregate_table_wraps_model_column_on_narrow_terminal() { + let view = AnalyzerStatsView { + daily_stats: BTreeMap::from([( + "2025-01-01".to_string(), + DailyStats { + date: CompactDate::from_str("2025-01-01").unwrap(), + models: BTreeMap::from([(String::from("a"), 1), (String::from("b"), 1)]), + ..DailyStats::default() + }, + )]), + session_aggregates: Vec::new(), + num_conversations: 1, + analyzer_name: Arc::from("test"), + }; + let format_options = crate::utils::NumberFormatOptions { + use_comma: false, + use_human: false, + locale: "en".to_string(), + currency_symbol: "$".to_string(), + cost_decimal_places: 2, + decimal_places: 2, + }; + let backend = TestBackend::new(115, 12); + let mut terminal = Terminal::new(backend).unwrap(); + let mut table_state = TableState::default(); + + terminal + .draw(|frame| { + draw_aggregate_stats_table( + frame, + Rect::new(0, 0, 115, 12), + &view, + &format_options, + &mut table_state, + AggregateViewMode::Daily, + "", + false, + false, + Color::Cyan, + &HashSet::new(), + false, + ); + }) + .unwrap(); + + let rendered = terminal + .backend() + .buffer() + .content + .iter() + .map(|cell| cell.symbol()) + .collect::(); + assert!(rendered.contains("a 50.0%")); + assert!(rendered.contains("b 50.0%")); +} + #[test] fn model_filter_recalculates_stats_and_sessions() { let date = CompactDate::from_str("2025-01-01").unwrap(); From 8e6976389b583759ed3eef1aba0eabb91f72f4fe Mon Sep 17 00:00:00 2001 From: jimyag Date: Wed, 9 Sep 2026 00:30:59 +0800 Subject: [PATCH 2/2] fix(tui): measure wrapped text by display width Use Ratatui terminal cell widths for model and app column sizing and for hard-wrap boundaries. Add coverage for wide Unicode model names so rendered content stays within the allocated columns. Signed-off-by: jimyag --- src/tui.rs | 46 +++++++++++++++++++++++++++++++++++----------- src/tui/tests.rs | 11 +++++++++-- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/tui.rs b/src/tui.rs index 4bb7eff..7b48075 100644 --- a/src/tui.rs +++ b/src/tui.rs @@ -2488,6 +2488,32 @@ fn format_model_usage_shares( /// Wrap model shares to the available column width without dropping any text. /// Model entries stay together when possible and are hard-wrapped only when a /// single entry is wider than the column. +fn terminal_text_width(text: &str) -> usize { + Line::from(text).width() +} + +fn split_terminal_text(text: &str, width: usize) -> Vec { + let width = width.max(1); + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_width = 0; + + for character in text.chars() { + let character_width = terminal_text_width(&character.to_string()); + if !current.is_empty() && current_width + character_width > width { + lines.push(std::mem::take(&mut current)); + current_width = 0; + } + current.push(character); + current_width += character_width; + } + + if !current.is_empty() { + lines.push(current); + } + lines +} + fn wrap_model_usage_text(text: &str, width: usize, style: Style) -> Text<'static> { let width = width.max(1); let mut lines = Vec::new(); @@ -2500,7 +2526,7 @@ fn wrap_model_usage_text(text: &str, width: usize, style: Style) -> Text<'static format!("{current}, {entry}") }; - if candidate.chars().count() <= width { + if terminal_text_width(&candidate) <= width { current = candidate; continue; } @@ -2509,16 +2535,14 @@ fn wrap_model_usage_text(text: &str, width: usize, style: Style) -> Text<'static lines.push(std::mem::take(&mut current)); } - if entry.chars().count() <= width { + if terminal_text_width(entry) <= width { current = entry.to_string(); continue; } - let mut entry_chars = entry.chars(); - while entry_chars.clone().count() > width { - lines.push(entry_chars.by_ref().take(width).collect()); - } - current = entry_chars.collect(); + let mut entry_lines = split_terminal_text(entry, width); + current = entry_lines.pop().unwrap_or_default(); + lines.extend(entry_lines); } if !current.is_empty() { @@ -2653,7 +2677,7 @@ fn draw_aggregate_stats_table( .get(period) .expect("visible period key must exist in aggregate stats"); let models = format_model_usage_shares(&period_stats.models, &period_stats.model_stats); - max_models_width = max_models_width.max(models.chars().count()); + max_models_width = max_models_width.max(terminal_text_width(&models)); for (model, count) in &period_stats.models { *width_total_models.entry(model.clone()).or_insert(0) += count; } @@ -2666,7 +2690,7 @@ fn draw_aggregate_stats_table( let mut apps_vec: Vec = period_stats.apps.keys().cloned().collect(); apps_vec.sort(); - max_apps_width = max_apps_width.max(apps_vec.join(", ").chars().count()); + max_apps_width = max_apps_width.max(terminal_text_width(&apps_vec.join(", "))); width_all_apps.extend(period_stats.apps.keys().cloned()); } @@ -2674,10 +2698,10 @@ fn draw_aggregate_stats_table( let width_all_models_text = format_model_usage_shares(&width_total_models, &width_total_model_stats); let mut apps_column_width = max_apps_width - .max(width_all_apps_text.chars().count()) + .max(terminal_text_width(&width_all_apps_text)) .clamp(APPS_COL_MIN_WIDTH, APPS_COL_MAX_WIDTH); let mut models_column_width = max_models_width - .max(width_all_models_text.chars().count()) + .max(terminal_text_width(&width_all_models_text)) .clamp(MODELS_COL_MIN_WIDTH, MODELS_COL_MAX_WIDTH); let mut fixed_width = 1usize + period_width as usize + 10; diff --git a/src/tui/tests.rs b/src/tui/tests.rs index 175e615..db0ce2b 100644 --- a/src/tui/tests.rs +++ b/src/tui/tests.rs @@ -1122,6 +1122,11 @@ fn model_usage_text_wraps_without_dropping_entries() { assert_eq!(wrapped.height(), 2); assert_eq!(wrapped.lines[0].to_string(), "model-a 60.0%"); assert_eq!(wrapped.lines[1].to_string(), "model-b 40.0%"); + + let wide_name = wrap_model_usage_text("模型名称 100.0%", 9, Color::Gray.into()); + assert!(wide_name.lines.iter().all(|line| line.width() <= 9)); + assert_eq!(wide_name.lines[0].to_string(), "模型名称 "); + assert_eq!(wide_name.lines[1].to_string(), "100.0%"); } #[test] @@ -1131,7 +1136,7 @@ fn aggregate_table_wraps_model_column_on_narrow_terminal() { "2025-01-01".to_string(), DailyStats { date: CompactDate::from_str("2025-01-01").unwrap(), - models: BTreeMap::from([(String::from("a"), 1), (String::from("b"), 1)]), + models: BTreeMap::from([(String::from("模型名称"), 1), (String::from("b"), 1)]), ..DailyStats::default() }, )]), @@ -1177,7 +1182,9 @@ fn aggregate_table_wraps_model_column_on_narrow_terminal() { .iter() .map(|cell| cell.symbol()) .collect::(); - assert!(rendered.contains("a 50.0%")); + for character in ["模", "型", "名", "称"] { + assert!(rendered.contains(character)); + } assert!(rendered.contains("b 50.0%")); }