Codex created this PR from the following prompt: > upgrade this entire repo to Rust 1.89. Note that this requires updating codex-rs/rust-toolchain.toml as well as the workflows in .github/. Make sure that things are "clippy clean" as this change will likely uncover new Clippy errors. `just fmt` and `cargo clippy --tests` are sufficient to check for correctness Note this modifies a lot of lines because it folds nested `if` statements using `&&`. --- [//]: # (BEGIN SAPLING FOOTER) Stack created with [Sapling](https://sapling-scm.com). Best reviewed with [ReviewStack](https://reviewstack.dev/openai/codex/pull/2465). * #2467 * __->__ #2465
268 lines
9.1 KiB
Rust
268 lines
9.1 KiB
Rust
use unicode_segmentation::UnicodeSegmentation;
|
|
|
|
/// 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.
|
|
/// This is a best-effort approach that may not work perfectly for text where 1 grapheme is rendered as multiple terminal cells.
|
|
pub(crate) fn format_and_truncate_tool_result(
|
|
text: &str,
|
|
max_lines: usize,
|
|
line_width: usize,
|
|
) -> String {
|
|
// Work out the maximum number of graphemes we can display for a result.
|
|
// It's not guaranteed that 1 grapheme = 1 cell, so we subtract 1 per line as a fudge factor.
|
|
// It also won't handle future terminal resizes properly, but it's an OK approximation for now.
|
|
let max_graphemes = (max_lines * line_width).saturating_sub(max_lines);
|
|
|
|
if let Some(formatted_json) = format_json_compact(text) {
|
|
truncate_text(&formatted_json, max_graphemes)
|
|
} else {
|
|
truncate_text(text, max_graphemes)
|
|
}
|
|
}
|
|
|
|
/// Format JSON text in a compact single-line format with spaces for better Ratatui wrapping.
|
|
/// Ex: `{"a":"b",c:["d","e"]}` -> `{"a": "b", "c": ["d", "e"]}`
|
|
/// Returns the formatted JSON string if the input is valid JSON, otherwise returns None.
|
|
/// This is a little complicated, but it's necessary because Ratatui's wrapping is *very* limited
|
|
/// and can only do line breaks at whitespace. If we use the default serde_json format, we get lines
|
|
/// without spaces that Ratatui can't wrap nicely. If we use the serde_json pretty format as-is,
|
|
/// it's much too sparse and uses too many terminal rows.
|
|
/// Relevant issue: https://github.com/ratatui/ratatui/issues/293
|
|
pub(crate) fn format_json_compact(text: &str) -> Option<String> {
|
|
let json = serde_json::from_str::<serde_json::Value>(text).ok()?;
|
|
let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
|
|
|
|
// Convert multi-line pretty JSON to compact single-line format by removing newlines and excess whitespace
|
|
let mut result = String::new();
|
|
let mut chars = json_pretty.chars().peekable();
|
|
let mut in_string = false;
|
|
let mut escape_next = false;
|
|
|
|
// Iterate over the characters in the JSON string, adding spaces after : and , but only when not in a string
|
|
while let Some(ch) = chars.next() {
|
|
match ch {
|
|
'"' if !escape_next => {
|
|
in_string = !in_string;
|
|
result.push(ch);
|
|
}
|
|
'\\' if in_string => {
|
|
escape_next = !escape_next;
|
|
result.push(ch);
|
|
}
|
|
'\n' | '\r' if !in_string => {
|
|
// Skip newlines when not in a string
|
|
}
|
|
' ' | '\t' if !in_string => {
|
|
// Add a space after : and , but only when not in a string
|
|
if let Some(&next_ch) = chars.peek()
|
|
&& let Some(last_ch) = result.chars().last()
|
|
&& (last_ch == ':' || last_ch == ',')
|
|
&& !matches!(next_ch, '}' | ']')
|
|
{
|
|
result.push(' ');
|
|
}
|
|
}
|
|
_ => {
|
|
if escape_next && in_string {
|
|
escape_next = false;
|
|
}
|
|
result.push(ch);
|
|
}
|
|
}
|
|
}
|
|
|
|
Some(result)
|
|
}
|
|
|
|
/// Truncate `text` to `max_graphemes` graphemes. Using graphemes to avoid accidentally truncating in the middle of a multi-codepoint character.
|
|
pub(crate) fn truncate_text(text: &str, max_graphemes: usize) -> String {
|
|
let mut graphemes = text.grapheme_indices(true);
|
|
|
|
// Check if there's a grapheme at position max_graphemes (meaning there are more than max_graphemes total)
|
|
if let Some((byte_index, _)) = graphemes.nth(max_graphemes) {
|
|
// There are more than max_graphemes, so we need to truncate
|
|
if max_graphemes >= 3 {
|
|
// Truncate to max_graphemes - 3 and add "..." to stay within limit
|
|
let mut truncate_graphemes = text.grapheme_indices(true);
|
|
if let Some((truncate_byte_index, _)) = truncate_graphemes.nth(max_graphemes - 3) {
|
|
let truncated = &text[..truncate_byte_index];
|
|
format!("{truncated}...")
|
|
} else {
|
|
text.to_string()
|
|
}
|
|
} else {
|
|
// max_graphemes < 3, so just return first max_graphemes without "..."
|
|
let truncated = &text[..byte_index];
|
|
truncated.to_string()
|
|
}
|
|
} else {
|
|
// There are max_graphemes or fewer graphemes, return original text
|
|
text.to_string()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn test_truncate_text() {
|
|
let text = "Hello, world!";
|
|
let truncated = truncate_text(text, 8);
|
|
assert_eq!(truncated, "Hello...");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_empty_string() {
|
|
let text = "";
|
|
let truncated = truncate_text(text, 5);
|
|
assert_eq!(truncated, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_max_graphemes_zero() {
|
|
let text = "Hello";
|
|
let truncated = truncate_text(text, 0);
|
|
assert_eq!(truncated, "");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_max_graphemes_one() {
|
|
let text = "Hello";
|
|
let truncated = truncate_text(text, 1);
|
|
assert_eq!(truncated, "H");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_max_graphemes_two() {
|
|
let text = "Hello";
|
|
let truncated = truncate_text(text, 2);
|
|
assert_eq!(truncated, "He");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_max_graphemes_three_boundary() {
|
|
let text = "Hello";
|
|
let truncated = truncate_text(text, 3);
|
|
assert_eq!(truncated, "...");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_text_shorter_than_limit() {
|
|
let text = "Hi";
|
|
let truncated = truncate_text(text, 10);
|
|
assert_eq!(truncated, "Hi");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_text_exact_length() {
|
|
let text = "Hello";
|
|
let truncated = truncate_text(text, 5);
|
|
assert_eq!(truncated, "Hello");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_emoji() {
|
|
let text = "👋🌍🚀✨💫";
|
|
let truncated = truncate_text(text, 3);
|
|
assert_eq!(truncated, "...");
|
|
|
|
let truncated_longer = truncate_text(text, 4);
|
|
assert_eq!(truncated_longer, "👋...");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_unicode_combining_characters() {
|
|
let text = "é́ñ̃"; // Characters with combining marks
|
|
let truncated = truncate_text(text, 2);
|
|
assert_eq!(truncated, "é́ñ̃");
|
|
}
|
|
|
|
#[test]
|
|
fn test_truncate_very_long_text() {
|
|
let text = "a".repeat(1000);
|
|
let truncated = truncate_text(&text, 10);
|
|
assert_eq!(truncated, "aaaaaaa...");
|
|
assert_eq!(truncated.len(), 10); // 7 'a's + 3 dots
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_simple_object() {
|
|
let json = r#"{ "name": "John", "age": 30 }"#;
|
|
let result = format_json_compact(json).unwrap();
|
|
assert_eq!(result, r#"{"name": "John", "age": 30}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_nested_object() {
|
|
let json = r#"{ "user": { "name": "John", "details": { "age": 30, "city": "NYC" } } }"#;
|
|
let result = format_json_compact(json).unwrap();
|
|
assert_eq!(
|
|
result,
|
|
r#"{"user": {"name": "John", "details": {"age": 30, "city": "NYC"}}}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_array() {
|
|
let json = r#"[ 1, 2, { "key": "value" }, "string" ]"#;
|
|
let result = format_json_compact(json).unwrap();
|
|
assert_eq!(result, r#"[1, 2, {"key": "value"}, "string"]"#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_already_compact() {
|
|
let json = r#"{"compact":true}"#;
|
|
let result = format_json_compact(json).unwrap();
|
|
assert_eq!(result, r#"{"compact": true}"#);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_with_whitespace() {
|
|
let json = r#"
|
|
{
|
|
"name": "John",
|
|
"hobbies": [
|
|
"reading",
|
|
"coding"
|
|
]
|
|
}
|
|
"#;
|
|
let result = format_json_compact(json).unwrap();
|
|
assert_eq!(
|
|
result,
|
|
r#"{"name": "John", "hobbies": ["reading", "coding"]}"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_invalid_json() {
|
|
let invalid_json = r#"{"invalid": json syntax}"#;
|
|
let result = format_json_compact(invalid_json);
|
|
assert!(result.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_empty_object() {
|
|
let json = r#"{}"#;
|
|
let result = format_json_compact(json).unwrap();
|
|
assert_eq!(result, "{}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_empty_array() {
|
|
let json = r#"[]"#;
|
|
let result = format_json_compact(json).unwrap();
|
|
assert_eq!(result, "[]");
|
|
}
|
|
|
|
#[test]
|
|
fn test_format_json_compact_primitive_values() {
|
|
assert_eq!(format_json_compact("42").unwrap(), "42");
|
|
assert_eq!(format_json_compact("true").unwrap(), "true");
|
|
assert_eq!(format_json_compact("false").unwrap(), "false");
|
|
assert_eq!(format_json_compact("null").unwrap(), "null");
|
|
assert_eq!(format_json_compact(r#""string""#).unwrap(), r#""string""#);
|
|
}
|
|
}
|