Skip to main content

supercode_frontend_tui/foundation/
text_formatting.rs

1// Derived from OpenAI Codex: codex-rs/tui/src/text_formatting.rs
2// Pinned source: 8604689ec5e3437eb79802d8d72249b7722fbf5b
3// Copyright 2025 OpenAI
4// Licensed under the Apache License, Version 2.0.
5// Modified by the Supercode contributors; see docs/legal/codex-frontend-extraction.toml.
6
7use unicode_segmentation::UnicodeSegmentation;
8use unicode_width::UnicodeWidthChar;
9use unicode_width::UnicodeWidthStr;
10
11pub fn capitalize_first(input: &str) -> String {
12    let mut chars = input.chars();
13    match chars.next() {
14        Some(first) => {
15            let mut capitalized = first.to_uppercase().collect::<String>();
16            capitalized.push_str(chars.as_str());
17            capitalized
18        }
19        None => String::new(),
20    }
21}
22
23/// Truncate a tool result to fit within the given height and width. If the text is valid JSON, we format it in a compact way before truncating.
24/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
25pub fn format_and_truncate_tool_result(text: &str, max_lines: usize, line_width: usize) -> String {
26    // Work out the maximum number of graphemes we can display for a result.
27    // It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor.
28    // It also won't handle future terminal resizes properly, but it's an OK approximation for now.
29    let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
30
31    if let Some(formatted_json) = format_json_compact(text) {
32        truncate_text(&formatted_json, max_graphemes)
33    } else {
34        truncate_text(text, max_graphemes)
35    }
36}
37
38/// Format JSON text in a compact single-line format with spaces for better Ratatui wrapping.
39/// Ex: `{"a":"b",c:["d","e"]}` -> `{"a": "b", "c": ["d", "e"]}`
40/// Returns the formatted JSON string if the input is valid JSON, otherwise returns None.
41/// This is a little complicated, but it's necessary because Ratatui's wrapping is *very* limited
42/// and can only do line breaks at whitespace. If we use the default serde_json format, we get lines
43/// without spaces that Ratatui can't wrap nicely. If we use the serde_json pretty format as-is,
44/// it's much too sparse and uses too many terminal rows.
45/// Relevant issue: https://github.com/ratatui/ratatui/issues/293
46pub fn format_json_compact(text: &str) -> Option<String> {
47    let json = serde_json::from_str::<serde_json::Value>(text).ok()?;
48    let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
49
50    // Convert multi-line pretty JSON to compact single-line format by removing newlines and excess whitespace
51    let mut result = String::new();
52    let mut chars = json_pretty.chars().peekable();
53    let mut in_string = false;
54    let mut escape_next = false;
55
56    // Iterate over the characters in the JSON string, adding spaces after : and , but only when not in a string
57    while let Some(ch) = chars.next() {
58        match ch {
59            '"' if !escape_next => {
60                in_string = !in_string;
61                result.push(ch);
62            }
63            '\\' if in_string => {
64                escape_next = !escape_next;
65                result.push(ch);
66            }
67            '\n' | '\r' if !in_string => {
68                // Skip newlines when not in a string
69            }
70            ' ' | '\t' if !in_string => {
71                // Add a space after : and , but only when not in a string
72                if let (Some(&next_ch), Some(last_ch)) = (chars.peek(), result.chars().last()) {
73                    if (last_ch == ':' || last_ch == ',') && !matches!(next_ch, '}' | ']') {
74                        result.push(' ');
75                    }
76                }
77            }
78            _ => {
79                if escape_next && in_string {
80                    escape_next = false;
81                }
82                result.push(ch);
83            }
84        }
85    }
86
87    Some(result)
88}
89
90/// Truncate `text` to `max_graphemes` graphemes. Using graphemes to avoid accidentally truncating in the middle of a multi-codepoint character.
91pub fn truncate_text(text: &str, max_graphemes: usize) -> String {
92    let mut graphemes = text.grapheme_indices(true);
93
94    // Check if there's a grapheme at position max_graphemes (meaning there are more than max_graphemes total)
95    if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
96        // There are more than max_graphemes, so we need to truncate
97        if max_graphemes >= 3 {
98            // Truncate to max_graphemes - 3 and add "..." to stay within limit
99            let mut truncate_graphemes = text.grapheme_indices(true);
100            if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) {
101                let truncated = &text[..truncate_byte_index];
102                format!("{truncated}...")
103            } else {
104                text.to_string()
105            }
106        } else {
107            // max_graphemes < 3, so just return first max_graphemes without "..."
108            let truncated = &text[..byte_index];
109            truncated.to_string()
110        }
111    } else {
112        // There are max_graphemes or fewer graphemes, return original text
113        text.to_string()
114    }
115}
116
117/// Truncate a path-like string to the given display width, keeping leading and trailing segments
118/// where possible and inserting a single Unicode ellipsis between them. If an individual segment
119/// cannot fit, it is front-truncated with an ellipsis.
120pub fn center_truncate_path(path: &str, max_width: usize) -> String {
121    if max_width == 0 {
122        return String::new();
123    }
124    if UnicodeWidthStr::width(path) <= max_width {
125        return path.to_string();
126    }
127
128    let sep = std::path::MAIN_SEPARATOR;
129    let has_leading_sep = path.starts_with(sep);
130    let has_trailing_sep = path.ends_with(sep);
131    let mut raw_segments: Vec<&str> = path.split(sep).collect();
132    if has_leading_sep && !raw_segments.is_empty() && raw_segments[0].is_empty() {
133        raw_segments.remove(0);
134    }
135    if has_trailing_sep
136        && !raw_segments.is_empty()
137        && raw_segments.last().is_some_and(|last| last.is_empty())
138    {
139        raw_segments.pop();
140    }
141
142    if raw_segments.is_empty() {
143        if has_leading_sep {
144            let root = sep.to_string();
145            if UnicodeWidthStr::width(root.as_str()) <= max_width {
146                return root;
147            }
148        }
149        return "…".to_string();
150    }
151
152    struct Segment<'a> {
153        original: &'a str,
154        text: String,
155        truncatable: bool,
156        is_suffix: bool,
157    }
158
159    let assemble = |leading: bool, segments: &[Segment<'_>]| -> String {
160        let mut result = String::new();
161        if leading {
162            result.push(sep);
163        }
164        for segment in segments {
165            if !result.is_empty() && !result.ends_with(sep) {
166                result.push(sep);
167            }
168            result.push_str(segment.text.as_str());
169        }
170        result
171    };
172
173    let front_truncate = |original: &str, allowed_width: usize| -> String {
174        if allowed_width == 0 {
175            return String::new();
176        }
177        if UnicodeWidthStr::width(original) <= allowed_width {
178            return original.to_string();
179        }
180        if allowed_width == 1 {
181            return "…".to_string();
182        }
183
184        let mut kept: Vec<char> = Vec::new();
185        let mut used_width = 1; // reserve space for leading ellipsis
186        for ch in original.chars().rev() {
187            let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
188            if used_width + ch_width > allowed_width {
189                break;
190            }
191            used_width += ch_width;
192            kept.push(ch);
193        }
194        kept.reverse();
195        let mut truncated = String::from("…");
196        for ch in kept {
197            truncated.push(ch);
198        }
199        truncated
200    };
201
202    let mut combos: Vec<(usize, usize)> = Vec::new();
203    let segment_count = raw_segments.len();
204    for left in 1..=segment_count {
205        let min_right = if left == segment_count { 0 } else { 1 };
206        for right in min_right..=(segment_count - left) {
207            combos.push((left, right));
208        }
209    }
210    let desired_suffix = if segment_count > 1 {
211        std::cmp::min(2, segment_count - 1)
212    } else {
213        0
214    };
215    let mut prioritized: Vec<(usize, usize)> = Vec::new();
216    let mut fallback: Vec<(usize, usize)> = Vec::new();
217    for combo in combos {
218        if combo.1 >= desired_suffix {
219            prioritized.push(combo);
220        } else {
221            fallback.push(combo);
222        }
223    }
224    let sort_combos = |items: &mut Vec<(usize, usize)>| {
225        items.sort_by(|(left_a, right_a), (left_b, right_b)| {
226            left_b
227                .cmp(left_a)
228                .then_with(|| right_b.cmp(right_a))
229                .then_with(|| (left_b + right_b).cmp(&(left_a + right_a)))
230        });
231    };
232    sort_combos(&mut prioritized);
233    sort_combos(&mut fallback);
234
235    let fit_segments =
236        |segments: &mut Vec<Segment<'_>>, allow_front_truncate: bool| -> Option<String> {
237            loop {
238                let candidate = assemble(has_leading_sep, segments);
239                let width = UnicodeWidthStr::width(candidate.as_str());
240                if width <= max_width {
241                    return Some(candidate);
242                }
243
244                if !allow_front_truncate {
245                    return None;
246                }
247
248                let mut indices: Vec<usize> = Vec::new();
249                for (idx, seg) in segments.iter().enumerate().rev() {
250                    if seg.truncatable && seg.is_suffix {
251                        indices.push(idx);
252                    }
253                }
254                for (idx, seg) in segments.iter().enumerate().rev() {
255                    if seg.truncatable && !seg.is_suffix {
256                        indices.push(idx);
257                    }
258                }
259
260                if indices.is_empty() {
261                    return None;
262                }
263
264                let mut changed = false;
265                for idx in indices {
266                    let original_width = UnicodeWidthStr::width(segments[idx].original);
267                    if original_width <= max_width && segment_count > 2 {
268                        continue;
269                    }
270                    let seg_width = UnicodeWidthStr::width(segments[idx].text.as_str());
271                    let other_width = width.saturating_sub(seg_width);
272                    let allowed_width = max_width.saturating_sub(other_width).max(1);
273                    let new_text = front_truncate(segments[idx].original, allowed_width);
274                    if new_text != segments[idx].text {
275                        segments[idx].text = new_text;
276                        changed = true;
277                        break;
278                    }
279                }
280
281                if !changed {
282                    return None;
283                }
284            }
285        };
286
287    for (left_count, right_count) in prioritized.into_iter().chain(fallback) {
288        let mut segments: Vec<Segment<'_>> = raw_segments[..left_count]
289            .iter()
290            .map(|seg| Segment {
291                original: seg,
292                text: (*seg).to_string(),
293                truncatable: true,
294                is_suffix: false,
295            })
296            .collect();
297
298        let need_ellipsis = left_count + right_count < segment_count;
299        if need_ellipsis {
300            segments.push(Segment {
301                original: "…",
302                text: "…".to_string(),
303                truncatable: false,
304                is_suffix: false,
305            });
306        }
307
308        if right_count > 0 {
309            segments.extend(
310                raw_segments[segment_count - right_count..]
311                    .iter()
312                    .map(|seg| Segment {
313                        original: seg,
314                        text: (*seg).to_string(),
315                        truncatable: true,
316                        is_suffix: true,
317                    }),
318            );
319        }
320
321        let allow_front_truncate = need_ellipsis || segment_count <= 2;
322        if let Some(candidate) = fit_segments(&mut segments, allow_front_truncate) {
323            return candidate;
324        }
325    }
326
327    front_truncate(path, max_width)
328}
329
330/// Join a list of strings with proper English punctuation.
331/// Examples:
332/// - [] -> ""
333/// - ["apple"] -> "apple"
334/// - ["apple", "banana"] -> "apple and banana"
335/// - ["apple", "banana", "cherry"] -> "apple, banana and cherry"
336pub fn proper_join<T: AsRef<str>>(items: &[T]) -> String {
337    match items.len() {
338        0 => String::new(),
339        1 => items[0].as_ref().to_string(),
340        2 => format!("{} and {}", items[0].as_ref(), items[1].as_ref()),
341        _ => {
342            let last = items[items.len() - 1].as_ref();
343            let mut result = String::new();
344
345            for (i, item) in items.iter().take(items.len() - 1).enumerate() {
346                if i > 0 {
347                    result.push_str(", ");
348                }
349                result.push_str(item.as_ref());
350            }
351
352            format!("{result} and {last}")
353        }
354    }
355}
356
357#[cfg(test)]
358mod tests {
359    use super::*;
360    use pretty_assertions::assert_eq;
361
362    #[test]
363    fn test_truncate_text() {
364        let text = "Hello, world!";
365        let truncated = truncate_text(text, /*max_graphemes*/ 8);
366        assert_eq!(truncated, "Hello...");
367    }
368
369    #[test]
370    fn test_truncate_empty_string() {
371        let text = "";
372        let truncated = truncate_text(text, /*max_graphemes*/ 5);
373        assert_eq!(truncated, "");
374    }
375
376    #[test]
377    fn test_truncate_max_graphemes_zero() {
378        let text = "Hello";
379        let truncated = truncate_text(text, /*max_graphemes*/ 0);
380        assert_eq!(truncated, "");
381    }
382
383    #[test]
384    fn test_truncate_max_graphemes_one() {
385        let text = "Hello";
386        let truncated = truncate_text(text, /*max_graphemes*/ 1);
387        assert_eq!(truncated, "H");
388    }
389
390    #[test]
391    fn test_truncate_max_graphemes_two() {
392        let text = "Hello";
393        let truncated = truncate_text(text, /*max_graphemes*/ 2);
394        assert_eq!(truncated, "He");
395    }
396
397    #[test]
398    fn test_truncate_max_graphemes_three_boundary() {
399        let text = "Hello";
400        let truncated = truncate_text(text, /*max_graphemes*/ 3);
401        assert_eq!(truncated, "...");
402    }
403
404    #[test]
405    fn test_truncate_text_shorter_than_limit() {
406        let text = "Hi";
407        let truncated = truncate_text(text, /*max_graphemes*/ 10);
408        assert_eq!(truncated, "Hi");
409    }
410
411    #[test]
412    fn test_truncate_text_exact_length() {
413        let text = "Hello";
414        let truncated = truncate_text(text, /*max_graphemes*/ 5);
415        assert_eq!(truncated, "Hello");
416    }
417
418    #[test]
419    fn test_truncate_emoji() {
420        let text = "👋🌍🚀✨💫";
421        let truncated = truncate_text(text, /*max_graphemes*/ 3);
422        assert_eq!(truncated, "...");
423
424        let truncated_longer = truncate_text(text, /*max_graphemes*/ 4);
425        assert_eq!(truncated_longer, "👋...");
426    }
427
428    #[test]
429    fn test_truncate_unicode_combining_characters() {
430        let text = "é́ñ̃"; // Characters with combining marks
431        let truncated = truncate_text(text, /*max_graphemes*/ 2);
432        assert_eq!(truncated, "é́ñ̃");
433    }
434
435    #[test]
436    fn test_truncate_very_long_text() {
437        let text = "a".repeat(1000);
438        let truncated = truncate_text(&text, /*max_graphemes*/ 10);
439        assert_eq!(truncated, "aaaaaaa...");
440        assert_eq!(truncated.len(), 10); // 7 'a's + 3 dots
441    }
442
443    #[test]
444    fn test_format_json_compact_simple_object() {
445        let json = r#"{ "name": "John", "age": 30 }"#;
446        let result = format_json_compact(json).unwrap();
447        assert_eq!(result, r#"{"name": "John", "age": 30}"#);
448    }
449
450    #[test]
451    fn test_format_json_compact_nested_object() {
452        let json = r#"{ "user": { "name": "John", "details": { "age": 30, "city": "NYC" } } }"#;
453        let result = format_json_compact(json).unwrap();
454        assert_eq!(
455            result,
456            r#"{"user": {"name": "John", "details": {"age": 30, "city": "NYC"}}}"#
457        );
458    }
459
460    #[test]
461    fn test_center_truncate_doesnt_truncate_short_path() {
462        let sep = std::path::MAIN_SEPARATOR;
463        let path = format!("{sep}Users{sep}codex{sep}Public");
464        let truncated = center_truncate_path(&path, /*max_width*/ 40);
465
466        assert_eq!(truncated, path);
467    }
468
469    #[test]
470    fn test_center_truncate_truncates_long_path() {
471        let sep = std::path::MAIN_SEPARATOR;
472        let path = format!("~{sep}hello{sep}the{sep}fox{sep}is{sep}very{sep}fast");
473        let truncated = center_truncate_path(&path, /*max_width*/ 24);
474
475        assert_eq!(
476            truncated,
477            format!("~{sep}hello{sep}the{sep}…{sep}very{sep}fast")
478        );
479    }
480
481    #[test]
482    fn test_center_truncate_truncates_long_windows_path() {
483        let sep = std::path::MAIN_SEPARATOR;
484        let path = format!(
485            "C:{sep}Users{sep}codex{sep}Projects{sep}super{sep}long{sep}windows{sep}path{sep}file.txt"
486        );
487        let truncated = center_truncate_path(&path, /*max_width*/ 36);
488
489        let expected = format!("C:{sep}Users{sep}codex{sep}…{sep}path{sep}file.txt");
490
491        assert_eq!(truncated, expected);
492    }
493
494    #[test]
495    fn test_center_truncate_handles_long_segment() {
496        let sep = std::path::MAIN_SEPARATOR;
497        let path = format!("~{sep}supercalifragilisticexpialidocious");
498        let truncated = center_truncate_path(&path, /*max_width*/ 18);
499
500        assert_eq!(truncated, format!("~{sep}…cexpialidocious"));
501    }
502
503    #[test]
504    fn test_format_json_compact_array() {
505        let json = r#"[ 1, 2, { "key": "value" }, "string" ]"#;
506        let result = format_json_compact(json).unwrap();
507        assert_eq!(result, r#"[1, 2, {"key": "value"}, "string"]"#);
508    }
509
510    #[test]
511    fn test_format_json_compact_already_compact() {
512        let json = r#"{"compact":true}"#;
513        let result = format_json_compact(json).unwrap();
514        assert_eq!(result, r#"{"compact": true}"#);
515    }
516
517    #[test]
518    fn test_format_json_compact_with_whitespace() {
519        let json = r#"
520        {
521            "name": "John",
522            "hobbies": [
523                "reading",
524                "coding"
525            ]
526        }
527        "#;
528        let result = format_json_compact(json).unwrap();
529        assert_eq!(
530            result,
531            r#"{"name": "John", "hobbies": ["reading", "coding"]}"#
532        );
533    }
534
535    #[test]
536    fn test_format_json_compact_invalid_json() {
537        let invalid_json = r#"{"invalid": json syntax}"#;
538        let result = format_json_compact(invalid_json);
539        assert!(result.is_none());
540    }
541
542    #[test]
543    fn test_format_json_compact_empty_object() {
544        let json = r#"{}"#;
545        let result = format_json_compact(json).unwrap();
546        assert_eq!(result, "{}");
547    }
548
549    #[test]
550    fn test_format_json_compact_empty_array() {
551        let json = r#"[]"#;
552        let result = format_json_compact(json).unwrap();
553        assert_eq!(result, "[]");
554    }
555
556    #[test]
557    fn test_format_json_compact_primitive_values() {
558        assert_eq!(format_json_compact("42").unwrap(), "42");
559        assert_eq!(format_json_compact("true").unwrap(), "true");
560        assert_eq!(format_json_compact("false").unwrap(), "false");
561        assert_eq!(format_json_compact("null").unwrap(), "null");
562        assert_eq!(format_json_compact(r#""string""#).unwrap(), r#""string""#);
563    }
564
565    #[test]
566    fn test_proper_join() {
567        let empty: Vec<String> = vec![];
568        assert_eq!(proper_join(&empty), "");
569        assert_eq!(proper_join(&["apple"]), "apple");
570        assert_eq!(proper_join(&["apple", "banana"]), "apple and banana");
571        assert_eq!(
572            proper_join(&["apple", "banana", "cherry"]),
573            "apple, banana and cherry"
574        );
575        assert_eq!(
576            proper_join(&["apple", "banana", "cherry", "date"]),
577            "apple, banana, cherry and date"
578        );
579    }
580}