feat(prompt): add support for wide characters in prompt preview
Some checks failed
Publish Docker Images / coverage-and-sonar (push) Failing after 12m12s

This commit is contained in:
2026-03-13 23:59:28 +01:00
parent 7fd4fcfc93
commit a560fc14de
5 changed files with 109 additions and 7 deletions

1
Cargo.lock generated
View File

@@ -1664,6 +1664,7 @@ dependencies = [
"textwrap", "textwrap",
"thiserror", "thiserror",
"tokio", "tokio",
"unicode-width",
] ]
[[package]] [[package]]

View File

@@ -31,6 +31,7 @@ lazy-regex = { version = "3.5.1", features = ["lite"] }
thiserror = "2.0.18" thiserror = "2.0.18"
tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.49.0", features = ["macros", "rt-multi-thread"] }
textwrap = "0.16.2" textwrap = "0.16.2"
unicode-width = "0.2.2"
[dev-dependencies] [dev-dependencies]
assert_cmd = "2.1.2" assert_cmd = "2.1.2"

View File

@@ -555,7 +555,10 @@ mod tests {
let result = Scope::parse(&input); let result = Scope::parse(&input);
assert_eq!( assert_eq!(
result.unwrap_err(), result.unwrap_err(),
ScopeError::TooLong { actual: 31, max: 30 }, ScopeError::TooLong {
actual: 31,
max: 30
},
"actual should be the char count (31), not the byte count (32)", "actual should be the char count (31), not the byte count (32)",
); );
} }

View File

@@ -6,6 +6,7 @@
//! in production while accepting mock implementations in tests. //! in production while accepting mock implementations in tests.
use inquire::{Confirm, Text}; use inquire::{Confirm, Text};
use unicode_width::UnicodeWidthStr;
use crate::{ use crate::{
commit::types::{BreakingChange, CommitType, Description, Scope}, commit::types::{BreakingChange, CommitType, Description, Scope},
@@ -40,14 +41,19 @@ pub trait Prompter: Send + Sync {
} }
fn format_message_box(message: &str) -> String { fn format_message_box(message: &str) -> String {
let preview_width = 72 + 2; // max width + space padding let preview_width = message
.split('\n')
.map(|line| line.width())
.max()
.unwrap_or(0)
.max(72);
let mut lines: Vec<String> = Vec::new(); let mut lines: Vec<String> = Vec::new();
lines.push(format!("{}", "".repeat(preview_width))); lines.push(format!("{}", "".repeat(preview_width + 2)));
for line in message.split("\n") { for line in message.split('\n') {
let padding = 72_usize.saturating_sub(line.chars().count()); let padding = preview_width.saturating_sub(line.width());
lines.push(format!("{line}{:padding$}", "")); lines.push(format!("{line}{:padding$}", ""));
} }
lines.push(format!("{}", "".repeat(preview_width))); lines.push(format!("{}", "".repeat(preview_width + 2)));
lines.join("\n") lines.join("\n")
} }
@@ -270,4 +276,91 @@ mod tests {
let expected = format!("{line_72}"); let expected = format!("{line_72}");
assert_eq!(lines[1], expected); assert_eq!(lines[1], expected);
} }
/// A single CJK character (display width 2) is padded as if it occupies 2 columns,
/// not 1 — so the right-hand padding is 70 spaces, not 71
#[test]
fn format_message_box_single_cjk_char() {
let result = format_message_box("");
let lines: Vec<&str> = result.split('\n').collect();
let expected = format!("│ 字{:70}", "");
assert_eq!(lines[1], expected);
}
/// A single emoji (display width 2) is padded as if it occupies 2 columns
#[test]
fn format_message_box_single_emoji() {
let result = format_message_box("🦀");
let lines: Vec<&str> = result.split('\n').collect();
let expected = format!("│ 🦀{:70}", "");
assert_eq!(lines[1], expected);
}
/// Mixed ASCII and CJK: padding accounts for the display width of the whole line
///
/// "feat: " = 6 display cols, "漢字" = 4 display cols → total 10, padding = 62
#[test]
fn format_message_box_mixed_ascii_and_cjk() {
let result = format_message_box("feat: 漢字");
let lines: Vec<&str> = result.split('\n').collect();
let expected = format!("│ feat: 漢字{:62}", "");
assert_eq!(lines[1], expected);
}
/// When a line exceeds 72 display columns the border expands to fit (width + 2 dashes)
#[test]
fn format_message_box_border_expands_beyond_72() {
let line_73 = "a".repeat(73);
let result = format_message_box(&line_73);
let lines: Vec<&str> = result.split('\n').collect();
let dashes = "".repeat(75); // 73 + 2
assert_eq!(lines[0], format!("{dashes}"));
assert_eq!(lines[lines.len() - 1], format!("{dashes}"));
}
/// A line that sets the box width gets zero right-hand padding
#[test]
fn format_message_box_widest_line_has_no_padding() {
let line_73 = "a".repeat(73);
let result = format_message_box(&line_73);
let lines: Vec<&str> = result.split('\n').collect();
assert_eq!(lines[1], format!("{line_73}"));
}
/// In a multi-line message, shorter lines are padded out to match the widest line
#[test]
fn format_message_box_shorter_lines_padded_to_widest() {
let long_line = "a".repeat(80);
let result = format_message_box(&format!("{long_line}\nshort"));
let lines: Vec<&str> = result.split('\n').collect();
assert_eq!(lines[1], format!("{long_line}"));
assert_eq!(lines[2], format!("│ short{:75}", "")); // 80 - 5 = 75
}
/// All rows have equal char count when the box expands beyond 72
#[test]
fn format_message_box_all_rows_same_width_when_expanded() {
let long_line = "a".repeat(80);
let result = format_message_box(&format!("{long_line}\nshort"));
let widths: Vec<usize> = result.split('\n').map(|l| l.chars().count()).collect();
let expected = widths[0];
assert!(
widths.iter().all(|&w| w == expected),
"rows have differing widths: {:?}",
widths
);
}
/// Wide characters can also trigger box expansion beyond 72 columns
///
/// 37 CJK characters × 2 display columns = 74 display columns → border uses 76 dashes
#[test]
fn format_message_box_wide_chars_expand_box() {
let wide_line = "".repeat(37); // 74 display cols
let result = format_message_box(&wide_line);
let lines: Vec<&str> = result.split('\n').collect();
let dashes = "".repeat(76); // 74 + 2
assert_eq!(lines[0], format!("{dashes}"));
assert_eq!(lines[1], format!("{wide_line}")); // no padding
}
} }

View File

@@ -606,7 +606,11 @@ mod tests {
let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts); let workflow = CommitWorkflow::with_prompts(mock_executor, mock_prompts);
let result: Result<(), Error> = workflow.run().await; let result: Result<(), Error> = workflow.run().await;
assert!(result.is_ok(), "expected workflow to succeed, got: {:?}", result); assert!(
result.is_ok(),
"expected workflow to succeed, got: {:?}",
result
);
let messages = workflow.executor.describe_messages(); let messages = workflow.executor.describe_messages();
assert_eq!(messages.len(), 1, "expected exactly one describe() call"); assert_eq!(messages.len(), 1, "expected exactly one describe() call");