From 966b5cf1fb2d595ce1428954e26269c71190b8ac Mon Sep 17 00:00:00 2001 From: Implementist <24910011+Implementist@users.noreply.github.com> Date: Wed, 3 Jun 2026 23:12:58 +0800 Subject: [PATCH] refactor(plan_prompt): use display-width in wrap_text, skip wasted render work - wrap_text: replace chars().count() with UnicodeWidthStr::width() so CJK text is wrapped by display columns, consistent with wrapped_line_count and ratatui's Paragraph::wrap. Also fix the hard-split loop to use exclusive byte ranges (..end) instead of inclusive (..=i) so multi-byte UTF-8 prefixes are always valid. - render: hoist the confirming_exit branch to an early return so the plan-content construction (lines, scroll bounds, footer) is skipped entirely when the confirmation dialog is visible. --- crates/tui/src/tui/plan_prompt.rs | 124 +++++++++++++++++------------- 1 file changed, 70 insertions(+), 54 deletions(-) diff --git a/crates/tui/src/tui/plan_prompt.rs b/crates/tui/src/tui/plan_prompt.rs index c03ba4c6..2cb6d7d0 100644 --- a/crates/tui/src/tui/plan_prompt.rs +++ b/crates/tui/src/tui/plan_prompt.rs @@ -319,6 +319,47 @@ impl ModalView for PlanPromptView { } fn render(&self, area: Rect, buf: &mut Buffer) { + // When the user pressed Esc after scrolling, show a confirmation prompt + // instead of the normal plan + options. Render it early so we skip the + // plan-content construction entirely. + if self.confirming_exit { + let confirm_lines = vec![ + Line::from(Span::styled( + "Exit without implementing?", + Style::default().fg(palette::DEEPSEEK_SKY).bold(), + )), + Line::from(""), + Line::from(Span::styled( + "You've scrolled through the plan content. Are you sure you want to exit?", + Style::default().fg(palette::TEXT_PRIMARY), + )), + Line::from(""), + Line::from(Span::styled( + " y — Yes, exit Plan mode", + Style::default().fg(palette::DEEPSEEK_SKY), + )), + Line::from(Span::styled( + " n / Esc — Cancel, go back to plan", + Style::default().fg(palette::TEXT_MUTED), + )), + ]; + let confirm_footer = Line::from(vec![ + Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)), + Span::raw(" "), + Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), + Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)), + ]); + let popup_area = centered_rect(66, 34, area); + render_modal_chrome(area, popup_area, buf); + let confirm = Paragraph::new(confirm_lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block(modal_block().title_bottom(confirm_footer)); + confirm.render(popup_area, buf); + return; + } + let popup_area = centered_rect(72, 52, area); let content_width = usize::from(popup_area.width.saturating_sub(4).max(1)); let mut lines: Vec = Vec::new(); @@ -417,53 +458,14 @@ impl ModalView for PlanPromptView { ); footer_spans.push(desc_span); - // When the user pressed Esc after scrolling, show a confirmation prompt - // instead of the normal plan + options. - if self.confirming_exit { - let confirm_lines = vec![ - Line::from(Span::styled( - "Exit without implementing?", - Style::default().fg(palette::DEEPSEEK_SKY).bold(), - )), - Line::from(""), - Line::from(Span::styled( - "You've scrolled through the plan content. Are you sure you want to exit?", - Style::default().fg(palette::TEXT_PRIMARY), - )), - Line::from(""), - Line::from(Span::styled( - " y — Yes, exit Plan mode", - Style::default().fg(palette::DEEPSEEK_SKY), - )), - Line::from(Span::styled( - " n / Esc — Cancel, go back to plan", - Style::default().fg(palette::TEXT_MUTED), - )), - ]; - let confirm_footer = Line::from(vec![ - Span::styled(" y ", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled("confirm exit", Style::default().fg(palette::TEXT_MUTED)), - Span::raw(" "), - Span::styled("n / Esc", Style::default().fg(palette::DEEPSEEK_SKY).bold()), - Span::styled(" cancel", Style::default().fg(palette::TEXT_MUTED)), - ]); - let popup_area = centered_rect(66, 34, area); - render_modal_chrome(area, popup_area, buf); - let confirm = Paragraph::new(confirm_lines) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) - .block(modal_block().title_bottom(confirm_footer)); - confirm.render(popup_area, buf); - } else { - render_modal_chrome(area, popup_area, buf); - let paragraph = Paragraph::new(lines) - .alignment(Alignment::Left) - .wrap(Wrap { trim: true }) - .block(modal_block().title_bottom(Line::from(footer_spans))) - .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); + render_modal_chrome(area, popup_area, buf); + let paragraph = Paragraph::new(lines) + .alignment(Alignment::Left) + .wrap(Wrap { trim: true }) + .block(modal_block().title_bottom(Line::from(footer_spans))) + .scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0)); - paragraph.render(popup_area, buf); - } + paragraph.render(popup_area, buf); } } @@ -481,21 +483,35 @@ fn wrap_text(text: &str, width: usize) -> Vec { let words: Vec<&str> = paragraph.split_whitespace().collect(); let mut current = String::new(); for word in words { - let word_width = word.chars().count(); + let word_width = UnicodeWidthStr::width(word); if word_width > width { if !current.is_empty() { lines.push(current.trim_end().to_string()); current.clear(); } - let mut chars = word.chars(); - loop { - let segment: String = chars.by_ref().take(width).collect(); - if segment.is_empty() { - break; + // Split an over-width word by display width, not code points, + // so CJK characters are measured consistently with + // wrapped_line_count and ratatui's Paragraph::wrap. + let mut remaining = word; + while !remaining.is_empty() { + let mut split_at = 0usize; + for (i, ch) in remaining.char_indices() { + // Use the exclusive byte range [..end) so the prefix is + // always valid UTF-8, even for multi-byte characters. + let end = i + ch.len_utf8(); + if UnicodeWidthStr::width(&remaining[..end]) > width { + break; + } + split_at = end; } - lines.push(segment); + if split_at == 0 { + // Even one character is wider than width; take it anyway. + split_at = remaining.chars().next().unwrap().len_utf8(); + } + lines.push(remaining[..split_at].to_string()); + remaining = &remaining[split_at..]; } - } else if current.chars().count() + 1 + word_width > width { + } else if UnicodeWidthStr::width(current.as_str()) + 1 + word_width > width { lines.push(current.trim_end().to_string()); current.clear(); current.push_str(word);