返回 CodeWhale
ui_text.rs
根目录 / crates / tui / src / tui / ui_text.rs
1 //! Shared text helpers for TUI selection and clipboard workflows.
2
3 use ratatui::text::{Line, Span};
4 use unicode_segmentation::UnicodeSegmentation;
5 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
6
7 use crate::tui::history::HistoryCell;
8 use crate::tui::osc8;
9
10 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
11 pub(crate) enum CopyLineSeparator {
12 None,
13 Space,
14 Newline,
15 }
16
17 impl CopyLineSeparator {
18 #[must_use]
19 pub(crate) const fn as_str(self) -> &'static str {
20 match self {
21 Self::None => "",
22 Self::Space => " ",
23 Self::Newline => "\n",
24 }
25 }
26 }
27
28 pub(crate) fn truncate_line_to_width(text: &str, max_width: usize) -> String {
29 if max_width == 0 {
30 return String::new();
31 }
32 if text_display_width(text) <= max_width {
33 return text.to_string();
34 }
35 // For very small budgets, take whole graphemes until the next one would
36 // exceed the display width. Never split an emoji or combining sequence.
37 if max_width <= 3 {
38 let mut out = String::new();
39 let mut width = 0usize;
40 for grapheme in text.graphemes(true) {
41 let grapheme_width = grapheme_display_width(grapheme);
42 if width + grapheme_width > max_width {
43 break;
44 }
45 out.push_str(grapheme);
46 width += grapheme_width;
47 }
48 return out;
49 }
50
51 let mut out = String::new();
52 let mut width = 0usize;
53 let limit = max_width.saturating_sub(3);
54 for grapheme in text.graphemes(true) {
55 let grapheme_width = grapheme_display_width(grapheme);
56 if width + grapheme_width > limit {
57 break;
58 }
59 out.push_str(grapheme);
60 width += grapheme_width;
61 }
62 out.push_str("...");
63 out
64 }
65
66 /// Truncate `text` to `max_width` display columns, preferring whole words.
67 pub(crate) fn semantic_truncate(text: &str, max_width: usize) -> String {
68 if max_width == 0 {
69 return String::new();
70 }
71 if text_display_width(text) <= max_width {
72 return text.to_string();
73 }
74
75 const ELLIPSIS: char = '…';
76 let ellipsis_width = char_display_width(ELLIPSIS);
77 let limit = max_width.saturating_sub(ellipsis_width);
78 if limit == 0 {
79 return ELLIPSIS.to_string();
80 }
81
82 let mut width = 0usize;
83 let mut cut_byte = 0usize;
84 let mut last_word_end = None;
85 let mut in_word = false;
86 for (byte_idx, grapheme) in text.grapheme_indices(true) {
87 let grapheme_width = grapheme_display_width(grapheme);
88 if width + grapheme_width > limit {
89 break;
90 }
91 width += grapheme_width;
92 cut_byte = byte_idx + grapheme.len();
93 if grapheme.chars().all(char::is_whitespace) {
94 if in_word {
95 last_word_end = Some(byte_idx);
96 in_word = false;
97 }
98 } else {
99 in_word = true;
100 }
101 }
102 if cut_byte == 0 {
103 return ELLIPSIS.to_string();
104 }
105
106 let mut body = if let Some(word_end) = last_word_end {
107 text[..word_end].trim_end()
108 } else {
109 text[..cut_byte].trim_end()
110 };
111 if body.is_empty() {
112 body = text[..cut_byte].trim_end();
113 }
114 let mut out = body.to_string();
115 out.push(ELLIPSIS);
116 out
117 }
118
119 pub(crate) fn semantic_truncate_with_affixes(
120 prefix: &str,
121 text: &str,
122 suffix: &str,
123 max_width: usize,
124 ) -> String {
125 let fixed_width = text_display_width(prefix) + text_display_width(suffix);
126 if fixed_width > max_width {
127 return semantic_truncate(&format!("{prefix}{text}{suffix}"), max_width);
128 }
129 format!(
130 "{prefix}{}{suffix}",
131 semantic_truncate_between_affixes(prefix, text, suffix, max_width)
132 )
133 }
134
135 pub(crate) fn semantic_truncate_between_affixes(
136 prefix: &str,
137 text: &str,
138 suffix: &str,
139 max_width: usize,
140 ) -> String {
141 let fixed_width = text_display_width(prefix) + text_display_width(suffix);
142 if fixed_width > max_width {
143 return String::new();
144 }
145 semantic_truncate(text, max_width - fixed_width)
146 }
147
148 pub(super) fn history_cell_to_text(cell: &HistoryCell, width: u16) -> String {
149 // Error detail/copy is a diagnostic boundary, not a screenshot of the
150 // live wrapping. Preserve the exact source message so narrow terminals do
151 // not insert newlines into hostnames, env vars, commands, or URLs.
152 if let HistoryCell::Error { message, .. } = cell {
153 return message.clone();
154 }
155 cell.transcript_lines(width)
156 .into_iter()
157 .map(line_to_string)
158 .collect::<Vec<_>>()
159 .join("\n")
160 }
161
162 /// Serialize one complete history cell for Copy Message.
163 ///
164 /// User and assistant cells have canonical source text. Returning it directly
165 /// preserves authored Markdown and hard line breaks while keeping role glyphs,
166 /// continuation rails, and visual wrapping out of the clipboard. Transcript
167 /// drag selections reuse this same projection per intersected cell
168 /// (`selection_to_markdown`, #6156); only the rendered-text fallback still
169 /// serializes the live cache, where Markdown has already been transformed and
170 /// user-message soft wraps do not carry join metadata.
171 ///
172 /// Complex cells intentionally retain the full-transcript representation.
173 /// Tool and thinking transcript renderers include semantic headers and complete
174 /// output that can differ from the capped or folded live cache.
175 pub(super) fn history_cell_to_clipboard_text(cell: &HistoryCell, width: u16) -> String {
176 match cell {
177 HistoryCell::User { content } | HistoryCell::Assistant { content, .. } => content.clone(),
178 _ => history_cell_to_text(cell, width),
179 }
180 }
181
182 fn line_to_string(line: Line<'static>) -> String {
183 let mut out = String::new();
184 append_spans_plain(line.spans.iter(), &mut out);
185 out
186 }
187
188 /// Convert a rendered transcript line to plain text, stripping OSC-8 link
189 /// escape sequences. The caller is responsible for shifting selection columns
190 /// to account for any visual-only rail prefix (see
191 /// `TranscriptViewCache::rail_prefix_width`).
192 pub(super) fn line_to_plain(line: &Line<'static>) -> String {
193 let mut out = String::new();
194 append_spans_plain(line.spans.iter(), &mut out);
195 out
196 }
197
198 fn append_spans_plain<'a, I>(spans: I, out: &mut String)
199 where
200 I: Iterator<Item = &'a Span<'a>>,
201 {
202 for span in spans {
203 if span.content.contains('\x1b') {
204 osc8::strip_into(&span.content, out);
205 } else {
206 out.push_str(span.content.as_ref());
207 }
208 }
209 }
210
211 pub(crate) fn text_display_width(text: &str) -> usize {
212 text.graphemes(true).map(grapheme_display_width).sum()
213 }
214
215 /// Visible width of one grapheme: ratatui strips control characters before
216 /// painting, so they occupy no cells. Every other grapheme keeps the shared
217 /// [`grapheme_display_width`] contract.
218 fn visible_grapheme_width(grapheme: &str) -> usize {
219 if grapheme.chars().any(|c| c.is_control()) {
220 0
221 } else {
222 grapheme_display_width(grapheme)
223 }
224 }
225
226 /// Display width in painted terminal columns: control characters are
227 /// invisible (ratatui strips them), so they add nothing.
228 ///
229 /// Mouse selection coordinates are terminal cells, so the copy path must
230 /// measure in this space: the fixed-4 [`text_display_width`] would shift
231 /// every column after a tab away from what the user dragged over. The two
232 /// agree on text without control characters.
233 pub(crate) fn text_visible_width(text: &str) -> usize {
234 text.graphemes(true).map(visible_grapheme_width).sum()
235 }
236
237 /// Slice `[start, end)` in painted terminal columns, the space the mouse
238 /// reports.
239 ///
240 /// A grapheme overlapping the window is kept whole under the same strict
241 /// rule for every width: zero-width control spans join the output only when
242 /// the window strictly covers their position, so interior tabs survive while
243 /// un-aimable edge touches stay out.
244 pub(crate) fn slice_visible_columns(text: &str, start: usize, end: usize) -> String {
245 if end <= start {
246 return String::new();
247 }
248
249 let mut out = String::new();
250 let mut col = 0usize;
251 for grapheme in text.graphemes(true) {
252 let grapheme_start = col;
253 let grapheme_end = col.saturating_add(visible_grapheme_width(grapheme));
254 if grapheme_end > start && grapheme_start < end {
255 out.push_str(grapheme);
256 }
257 col = grapheme_end;
258 if col >= end {
259 break;
260 }
261 }
262 out
263 }
264
265 pub(super) fn char_display_width(ch: char) -> usize {
266 match ch {
267 '\t' => 4,
268 // Enclosed Alphanumerics (U+2460-U+24FF), Dingbat Circled Digits
269 // (U+2776-U+2793), and Circled Numbers on Black Square (U+3248-U+324F)
270 // have East Asian Width "Ambiguous" but are rendered as 2 columns in
271 // CJK terminals. unicode-width's width() conservatively reports 1; we
272 // match the terminal. (#4479)
273 '\u{2460}'..='\u{24FF}' | '\u{2776}'..='\u{2793}' | '\u{3248}'..='\u{324F}' => 2,
274 _ => {
275 // `width()` returns `None` for control/unassigned chars (default them to
276 // one column so layout doesn't collapse) and `Some(0)` for genuinely
277 // zero-width chars — combining marks, ZWJ, zero-width spaces — which must
278 // stay 0 so display-width math (truncation, slicing, overflow, copy)
279 // matches what the terminal actually renders.
280 UnicodeWidthChar::width(ch).unwrap_or(1)
281 }
282 }
283 }
284
285 /// Measure one extended grapheme using the same string-level Unicode rules as
286 /// Ratatui. String width intentionally differs from the sum of codepoint widths
287 /// for keycaps, ZWJ emoji, modifiers, and other terminal ligatures.
288 ///
289 /// Keycap sequences (such as 1\u{fe0f}\u{20e3}) that lack an FE0F variation
290 /// selector still render as 2 columns in terminals, but unicode-width's
291 /// `grapheme.width()` only reports 1. We force 2 when U+20E3 is present in a
292 /// multi-codepoint grapheme. (#4479)
293 pub(super) fn grapheme_display_width(grapheme: &str) -> usize {
294 if grapheme == "\t" {
295 return 4;
296 }
297 if let Some(ch) = grapheme.chars().next()
298 && ch.len_utf8() == grapheme.len()
299 {
300 return char_display_width(ch);
301 }
302 // Keycap sequences always render as 2 columns. unicode-width's
303 // `width()` undercounts the non-FE0F variant to 1.
304 if grapheme.contains('\u{20e3}') {
305 return 2;
306 }
307 UnicodeWidthStr::width(grapheme)
308 }
309
310 #[cfg(test)]
311 mod tests {
312 use super::*;
313 use ratatui::text::Span;
314
315 #[test]
316 fn line_to_plain_strips_osc_8_wrapper() {
317 let wrapped = format!(
318 "\x1b]8;;{}\x1b\\{}\x1b]8;;\x1b\\",
319 "https://example.com", "https://example.com"
320 );
321 let line = Line::from(vec![
322 Span::raw("see "),
323 Span::raw(wrapped),
324 Span::raw(" for details"),
325 ]);
326 let text = line_to_plain(&line);
327 assert_eq!(text, "see https://example.com for details");
328 }
329
330 #[test]
331 fn line_to_plain_passes_through_plain_spans() {
332 let line = Line::from(vec![Span::raw("plain "), Span::raw("text")]);
333 let text = line_to_plain(&line);
334 assert_eq!(text, "plain text");
335 }
336
337 #[test]
338 fn line_to_plain_includes_all_spans() {
339 // Visual-only rail spans are stripped by the caller using
340 // TranscriptViewCache::rail_prefix_width — line_to_plain itself
341 // is a faithful span-to-string pass-through.
342 let line = Line::from(vec![Span::raw("\u{2502} "), Span::raw("tool output")]);
343 let text = line_to_plain(&line);
344 assert_eq!(text, "\u{2502} tool output");
345 }
346
347 #[test]
348 fn slice_text_respects_column_bounds() {
349 let text = "hello world";
350 assert_eq!(slice_visible_columns(text, 0, 5), "hello");
351 assert_eq!(slice_visible_columns(text, 6, 11), "world");
352 assert_eq!(slice_visible_columns(text, 0, 0), "");
353 assert_eq!(slice_visible_columns(text, 0, 100), text);
354 }
355
356 #[test]
357 fn slice_text_handles_multibyte_characters() {
358 let text = "a─b"; // U+2500 is 1 display column on supported terminals
359 assert_eq!(slice_visible_columns(text, 1, 2), "─");
360 assert_eq!(slice_visible_columns(text, 0, 3), text);
361 }
362
363 #[test]
364 fn slice_text_truncates_at_end() {
365 let text = "ab";
366 assert_eq!(slice_visible_columns(text, 1, 5), "b");
367 }
368
369 #[test]
370 fn visible_width_ignores_stripped_controls() {
371 assert_eq!(text_visible_width("\t"), 0);
372 assert_eq!(text_visible_width("\ta"), 1);
373 assert_eq!(text_visible_width("ab\t"), 2);
374 assert_eq!(text_visible_width("a\tb"), 2);
375 }
376
377 #[test]
378 fn visible_slice_keeps_interior_controls_only() {
379 // Interior tab strictly inside the window survives.
380 assert_eq!(slice_visible_columns("ab\tcdef", 0, 4), "ab\tcd");
381 // Zero-width span at the window edge stays out, like any grapheme.
382 assert_eq!(slice_visible_columns("\txy", 0, 2), "xy");
383 assert_eq!(slice_visible_columns("ab", 0, 2), "ab");
384 }
385
386 // --- Unicode / CJK / terminal-width QA (issue #3488) -------------------
387 // These exercise the production width helpers directly so the assertions
388 // track the same code path the renderer uses.
389
390 #[test]
391 fn text_display_width_counts_cjk_as_two_columns() {
392 assert_eq!(text_display_width("中文"), 4); // two wide glyphs
393 assert_eq!(text_display_width("Hello世界"), 9); // 5 ASCII + 2×2
394 // Full-width (ambiguous→wide) punctuation is two columns each.
395 assert_eq!(text_display_width(",。!?"), 8);
396 }
397
398 #[test]
399 fn text_display_width_treats_zero_width_marks_as_zero() {
400 // A combining mark adds no column: "e" + U+0301 renders as one cell.
401 // (Regression guard: the old `.max(1)` counted it as 1, over-reporting
402 // width and causing premature truncation / border drift on text with
403 // combining marks or ZWJ emoji sequences.)
404 assert_eq!(text_display_width("e\u{0301}"), 1);
405 assert_eq!(text_display_width("cafe\u{0301}"), 4);
406 // The complete ZWJ emoji is one two-column grapheme, matching Ratatui.
407 assert_eq!(text_display_width("\u{1F469}\u{200D}\u{1F4BB}"), 2);
408 }
409
410 #[test]
411 fn text_display_width_keeps_control_and_tab_widths() {
412 // Control chars still occupy a column (avoid layout collapse); tab = 4.
413 assert_eq!(text_display_width("a\u{0007}b"), 3);
414 assert_eq!(text_display_width("\t"), 4);
415 assert_eq!(text_display_width("\ta"), 5);
416 }
417
418 #[test]
419 fn truncate_line_to_width_respects_display_width_not_byte_len() {
420 // No truncation when the string already fits by display width.
421 assert_eq!(truncate_line_to_width("中文", 10), "中文");
422 // Oversized: reserve 3 cols for the ellipsis, fill the rest by width.
423 let out = truncate_line_to_width("中文测试", 7);
424 assert_eq!(out, "中文...");
425 assert_eq!(text_display_width(&out), 7);
426 // Never split a wide glyph across the boundary, and never emit U+FFFD.
427 let clipped = truncate_line_to_width("界界界界界", 5);
428 assert!(text_display_width(&clipped) <= 5);
429 assert!(!clipped.contains('\u{FFFD}'));
430 }
431
432 #[test]
433 fn semantic_truncate_prefers_word_boundaries() {
434 let out = semantic_truncate("hello world foo bar", 14);
435 assert_eq!(out, "hello world…");
436 assert!(text_display_width(&out) <= 14);
437 }
438
439 #[test]
440 fn semantic_truncate_falls_back_with_long_words_and_wide_glyphs() {
441 let long_word = semantic_truncate("supercalifragilistic", 8);
442 assert_eq!(long_word, "superca…");
443 assert!(text_display_width(&long_word) <= 8);
444
445 let cjk = semantic_truncate("中文测试文本", 7);
446 assert_eq!(cjk, "中文测…");
447 assert!(text_display_width(&cjk) <= 7);
448 }
449
450 #[test]
451 fn semantic_truncate_handles_empty_and_tiny_budgets() {
452 assert_eq!(semantic_truncate("", 10), "");
453 assert_eq!(semantic_truncate("hello", 0), "");
454 assert_eq!(semantic_truncate("hello", 1), "…");
455 }
456
457 #[test]
458 fn semantic_truncate_between_affixes_reserves_fixed_columns() {
459 let hint = semantic_truncate_between_affixes(
460 " > [ ] Prefix stability (",
461 "whether system/tools stayed cacheable",
462 ")",
463 49,
464 );
465 let row = format!(" > [ ] Prefix stability ({hint})");
466 assert_eq!(hint, "whether system/tools…");
467 assert!(text_display_width(&row) <= 49);
468 }
469
470 #[test]
471 fn slice_text_slices_cjk_by_display_column() {
472 // Columns: 中=[0,2) 文=[2,4) a=[4,5) b=[5,6)
473 let text = "中文ab";
474 assert_eq!(slice_visible_columns(text, 0, 2), "中");
475 assert_eq!(slice_visible_columns(text, 2, 4), "文");
476 assert_eq!(slice_visible_columns(text, 4, 6), "ab");
477 }
478
479 // --- New #3488 fixtures: CJK/wide-glyph truncation on selector-style rows.
480 // truncate_line_to_width is the production helper behind sidebar (file_tree),
481 // statusline (footer_ui), hotbar, and picker (mouse_ui) row rendering, so
482 // these exercise the same truncation path those surfaces use.
483
484 #[test]
485 fn truncate_line_to_width_full_width_cjk_lands_on_glyph_boundary() {
486 // Each Han glyph is two columns. With an odd budget the truncation must
487 // land on a whole-glyph boundary (reserving three columns for the
488 // ellipsis), never leaving a half-rendered wide cell or emitting U+FFFD.
489 let title = "项目报告结果"; // 6 glyphs, 12 columns
490 let out = truncate_line_to_width(title, 7);
491 // Budget 7 -> limit 4 columns -> two glyphs fit, then the ellipsis.
492 assert_eq!(out, "项目...");
493 assert_eq!(text_display_width(&out), 7);
494 // The kept prefix is composed only of whole wide glyphs (each 2 cols),
495 // proving the boundary glyph was dropped whole, not split.
496 let prefix = out.strip_suffix("...").expect("ellipsis present");
497 assert!(prefix.chars().all(|c| char_display_width(c) == 2));
498 assert!(!out.contains('\u{FFFD}'));
499 }
500
501 #[test]
502 fn truncate_line_to_width_mixed_ascii_cjk_row_keeps_ellipsis_within_budget() {
503 // A sidebar/selector row mixing an ASCII label with a CJK title, wider
504 // than the column budget, must truncate with a trailing ellipsis that
505 // still fits by display width and must not split a wide glyph.
506 let row = "Task: 数据库迁移任务 done"; // ASCII label + 7 Han glyphs
507 let budget = 12;
508 let out = truncate_line_to_width(row, budget);
509 assert!(out.ends_with("..."), "expected ellipsis, got {out:?}");
510 // Ellipsis-and-content fit within the budget by *display* width.
511 assert!(text_display_width(&out) <= budget);
512 // The non-ellipsis prefix stays within budget-minus-ellipsis, so the
513 // wide glyph on the boundary was dropped whole rather than half-drawn.
514 let prefix = out.strip_suffix("...").expect("ellipsis present");
515 assert!(text_display_width(prefix) <= budget - 3);
516 assert!(!out.contains('\u{FFFD}'));
517 // The semantic ASCII prefix survives truncation.
518 assert!(out.starts_with("Task:"));
519 }
520
521 #[test]
522 fn truncate_line_to_width_dense_cjk_selector_row_survives_narrow_widths() {
523 // Picker/selector rows degrade through truncate_line_to_width when the
524 // terminal is narrow. A dense row with a leading marker glyph and CJK
525 // content must stay within budget at tiny widths, without panicking or
526 // emitting a replacement char from a mid-glyph byte split.
527 let row = "▸ 中文项目 · main"; // marker + CJK + separator + branch
528 for width in [1usize, 2, 3, 4, 6, 8] {
529 let out = truncate_line_to_width(row, width);
530 assert!(
531 text_display_width(&out) <= width,
532 "width={width}: {out:?} exceeds budget"
533 );
534 assert!(
535 !out.contains('\u{FFFD}'),
536 "width={width}: truncation split a wide glyph"
537 );
538 }
539 }
540
541 // --- keycap / grapheme regression guard (#4479) ---------------------------
542 // Fully qualified keycap sequences render as two columns. Codepoint sums
543 // report one; the canonical string/grapheme contract reports two.
544
545 #[test]
546 fn text_display_width_treats_keycap_sequence_as_two_columns() {
547 for keycap in [
548 "1\u{fe0f}\u{20e3}",
549 "9\u{fe0f}\u{20e3}",
550 "#\u{fe0f}\u{20e3}",
551 ] {
552 assert_eq!(text_display_width(keycap), 2);
553 assert_eq!(text_display_width(keycap), UnicodeWidthStr::width(keycap));
554 }
555 // A digit directly followed by U+20E3 (without FE0F variation selector)
556 // still renders as a 2-column keycap in terminals. We force this in
557 // grapheme_display_width when the grapheme contains U+20E3.
558 // A standalone U+20E3 is a zero-width combining mark.
559 assert_eq!(text_display_width("1\u{20e3}"), 2);
560 assert_eq!(text_display_width("\u{20e3}"), 0);
561 }
562
563 #[test]
564 fn circled_digit_display_width() {
565 assert_eq!(char_display_width('\u{2460}'), 2);
566 assert_eq!(char_display_width('\u{2461}'), 2);
567 assert_eq!(char_display_width('\u{24ea}'), 2);
568 assert_eq!(char_display_width('\u{2776}'), 2);
569 assert_eq!(text_display_width("\u{2460}\u{2461}\u{2462}"), 6);
570 assert_eq!(text_display_width("Step \u{2460}: init"), 13);
571 assert_eq!(text_display_width("A\u{24d0}B"), 4);
572 }
573
574 #[test]
575 fn unicode_width_reports_circled_digits_as_two_columns() {
576 // Regression guard for the unicode-width patch (#4479): Ratatui
577 // renders text through UnicodeWidthChar::width(), so the patch must
578 // make even the raw crate API report 2 columns for ambiguous-width
579 // characters — otherwise Ratatui places them in 1 cell while the
580 // terminal paints 2, shifting every downstream column.
581 assert_eq!(UnicodeWidthChar::width('\u{2460}'), Some(2));
582 assert_eq!(UnicodeWidthChar::width('\u{24ea}'), Some(2));
583 assert_eq!(UnicodeWidthStr::width("\u{2460}\u{2461}\u{2462}"), 6);
584 }
585
586 #[test]
587 fn slice_text_does_not_split_keycap_sequence() {
588 let row = "step 1\u{fe0f}\u{20e3} done";
589 // The keycap occupies columns [5, 7). Any overlapping selection keeps
590 // the complete grapheme; no isolated FE0F/U+20E3 mark may escape.
591 for (start, end) in [(0, 7), (5, 6), (6, 7)] {
592 let sliced = slice_visible_columns(row, start, end);
593 assert!(
594 sliced.contains("1\u{fe0f}\u{20e3}"),
595 "range=({start}, {end}) split keycap: {sliced:?}"
596 );
597 }
598 }
599
600 #[test]
601 fn truncate_line_to_width_always_stays_within_budget_with_keycap() {
602 // Budgets from zero through wide, with and without surrounding text.
603 let cases = [
604 "1\u{fe0f}\u{20e3}",
605 "A 1\u{fe0f}\u{20e3} B",
606 "step 2\u{fe0f}\u{20e3} and 3\u{fe0f}\u{20e3} continue",
607 ];
608 for text in &cases {
609 for budget in 0..=text_display_width(text) + 4 {
610 let out = truncate_line_to_width(text, budget);
611 let width = text_display_width(&out);
612 assert!(
613 width <= budget,
614 "budget={budget} text={text:?} -> {out:?} (width={width})"
615 );
616 assert!(!out.ends_with('\u{fe0f}'));
617 assert!(!out.starts_with('\u{20e3}'));
618 }
619 }
620 }
621
622 #[test]
623 fn clipboard_text_for_assistant_uses_source_without_visual_rails() {
624 let content = "A long assistant response that will wrap at a narrow width.";
625 let cell = HistoryCell::Assistant {
626 content: content.to_string(),
627 streaming: false,
628 };
629
630 let rendered = history_cell_to_text(&cell, 12);
631 assert_ne!(rendered, content, "test setup must exercise rendering");
632 assert!(
633 rendered.contains('\n'),
634 "test setup must exercise visual wrapping: {rendered:?}"
635 );
636 assert_eq!(history_cell_to_clipboard_text(&cell, 12), content);
637 }
638
639 #[test]
640 fn clipboard_text_preserves_authored_rail_glyphs() {
641 let content = "● literal role glyph\n▏ literal rail glyph";
642 let cell = HistoryCell::Assistant {
643 content: content.to_string(),
644 streaming: false,
645 };
646
647 assert_eq!(history_cell_to_clipboard_text(&cell, 10), content);
648 }
649
650 #[test]
651 fn clipboard_text_preserves_markdown_source_and_hard_breaks() {
652 let content = r#"Heading
653
654 ```rust
655 fn main() {
656 println!("hello");
657 }
658 ```
659
660 After code."#;
661 let cell = HistoryCell::Assistant {
662 content: content.to_string(),
663 streaming: false,
664 };
665
666 assert_eq!(history_cell_to_clipboard_text(&cell, 16), content);
667 }
668
669 #[test]
670 fn clipboard_text_for_user_uses_source_text() {
671 let content = "user text that wraps visually";
672 let cell = HistoryCell::User {
673 content: content.to_string(),
674 };
675
676 let rendered = history_cell_to_text(&cell, 8);
677 assert_ne!(rendered, content, "test setup must exercise rendering");
678 assert!(
679 rendered.contains('\n'),
680 "test setup must exercise visual wrapping: {rendered:?}"
681 );
682 assert_eq!(history_cell_to_clipboard_text(&cell, 8), content);
683 }
684
685 #[test]
686 fn clipboard_text_for_thinking_keeps_full_transcript_semantics() {
687 let content = "First paragraph lede.\n\
688 Second sentence of the first paragraph.\n\n\
689 Second paragraph: deeper analysis follows.\n\
690 More detail in paragraph two.\n\n\
691 Third paragraph: even more reasoning.\n\
692 With another line.\n\n\
693 Fourth paragraph: the conclusion.\n\
694 And one more line for good measure.\n\n\
695 Fifth paragraph: final verification.\n\
696 One last supporting detail.";
697 let cell = HistoryCell::Thinking {
698 content: content.to_string(),
699 streaming: false,
700 duration_secs: Some(3.2),
701 };
702
703 let live = cell
704 .lines_with_options(
705 80,
706 crate::tui::history::TranscriptRenderOptions {
707 low_motion: true,
708 ..crate::tui::history::TranscriptRenderOptions::default()
709 },
710 )
711 .into_iter()
712 .map(line_to_string)
713 .collect::<Vec<_>>()
714 .join("\n");
715 let transcript = history_cell_to_text(&cell, 80);
716 let copied = history_cell_to_clipboard_text(&cell, 80);
717
718 assert!(!live.contains("Fifth paragraph"), "{live:?}");
719 assert!(transcript.contains("Fifth paragraph"), "{transcript:?}");
720 assert_eq!(copied, transcript);
721 }
722
723 #[test]
724 fn clipboard_text_for_tool_keeps_full_transcript_semantics() {
725 use crate::tui::history::{GenericToolCell, ToolCell, ToolStatus};
726
727 let cell = HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
728 name: "exec_shell".to_string(),
729 status: ToolStatus::Success,
730 input_summary: Some("cargo test".to_string()),
731 output: Some("complete tool output".to_string()),
732 prompts: None,
733 spillover_path: None,
734 output_summary: None,
735 is_diff: false,
736 }));
737
738 let transcript = history_cell_to_text(&cell, 80);
739 assert!(
740 transcript.contains("complete tool output"),
741 "{transcript:?}"
742 );
743 assert_eq!(history_cell_to_clipboard_text(&cell, 80), transcript);
744 }
745 }
746
746 lines RUST