mirror of
https://github.com/openai/codex.git
synced 2026-04-28 08:34:54 +00:00
29 KiB
29 KiB
PR #1211: codex-rs: make tool calls prettier
- URL: https://github.com/openai/codex/pull/1211
- Author: rgwood-dd
- Created: 2025-06-03 18:48:39 UTC
- Updated: 2025-06-03 21:29:36 UTC
- Changes: +352/-50, Files changed: 7, Commits: 4
Description
This PR overhauls how active tool calls and completed tool calls are displayed:
- More use of colour to indicate success/failure and distinguish between components like tool name+arguments
- Previously, the entire
CallToolResultwas serialized to JSON and pretty-printed. Now, we extract each individualCallToolResultContentand print those- The previous solution was wasting space by unnecessarily showing details of the
CallToolResultstruct to users, without formatting the actual tool call results nicely - We're now able to show users more information from tool results in less space, with nicer formatting when tools return JSON results
- The previous solution was wasting space by unnecessarily showing details of the
Before:
After:
Future Work
- Integrate image tool result handling better. We should be able to display images even if they're not the first
CallToolResultContent - Users should have some way to view the full version of truncated tool results
- It would be nice to add some left padding for tool results, make it more clear that they are results. This is doable, just a little fiddly due to the way
first_visible_linescrolling works - There's almost certainly a better way to format JSON than "all on 1 line with spaces to make Ratatui wrapping work". But I think that works OK for now.
Full Diff
diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock
index 694e11383f..ae574ba303 100644
--- a/codex-rs/Cargo.lock
+++ b/codex-rs/Cargo.lock
@@ -768,6 +768,7 @@ dependencies = [
"tui-input",
"tui-markdown",
"tui-textarea",
+ "unicode-segmentation",
"uuid",
]
diff --git a/codex-rs/mcp-types/src/lib.rs b/codex-rs/mcp-types/src/lib.rs
index afd6f4ad63..0ed518535f 100644
--- a/codex-rs/mcp-types/src/lib.rs
+++ b/codex-rs/mcp-types/src/lib.rs
@@ -1144,6 +1144,7 @@ pub enum ServerRequest {
#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
#[serde(untagged)]
+#[allow(clippy::large_enum_variant)]
pub enum ServerResult {
Result(Result),
InitializeResult(InitializeResult),
diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml
index 235f5f0c7a..ffc107e831 100644
--- a/codex-rs/tui/Cargo.toml
+++ b/codex-rs/tui/Cargo.toml
@@ -34,7 +34,7 @@ ratatui = { version = "0.29.0", features = [
] }
ratatui-image = "8.0.0"
regex-lite = "0.1"
-serde_json = "1"
+serde_json = { version = "1", features = ["preserve_order"] }
shlex = "1.3.0"
strum = "0.27.1"
strum_macros = "0.27.1"
@@ -51,6 +51,7 @@ tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }
tui-input = "0.11.1"
tui-markdown = "0.3.3"
tui-textarea = "0.7.0"
+unicode-segmentation = "1.12.0"
uuid = "1"
[dev-dependencies]
diff --git a/codex-rs/tui/src/conversation_history_widget.rs b/codex-rs/tui/src/conversation_history_widget.rs
index 9242e00389..a23e00d776 100644
--- a/codex-rs/tui/src/conversation_history_widget.rs
+++ b/codex-rs/tui/src/conversation_history_widget.rs
@@ -299,7 +299,6 @@ impl ConversationHistoryWidget {
for entry in self.entries.iter_mut() {
if let HistoryCell::ActiveMcpToolCall {
call_id: history_id,
- fq_tool_name,
invocation,
start,
..
@@ -307,7 +306,7 @@ impl ConversationHistoryWidget {
{
if &call_id == history_id {
let completed = HistoryCell::new_completed_mcp_tool_call(
- fq_tool_name.clone(),
+ width,
invocation.clone(),
*start,
success,
diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs
index a1fc672c6b..481576b5b3 100644
--- a/codex-rs/tui/src/history_cell.rs
+++ b/codex-rs/tui/src/history_cell.rs
@@ -2,6 +2,7 @@ use crate::cell_widget::CellWidget;
use crate::exec_command::escape_command;
use crate::markdown::append_markdown;
use crate::text_block::TextBlock;
+use crate::text_formatting::format_and_truncate_tool_result;
use base64::Engine;
use codex_ansi_escape::ansi_escape_line;
use codex_common::elapsed::format_duration;
@@ -14,6 +15,7 @@ use image::DynamicImage;
use image::GenericImageView;
use image::ImageReader;
use lazy_static::lazy_static;
+use mcp_types::EmbeddedResourceResource;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
@@ -73,18 +75,14 @@ pub(crate) enum HistoryCell {
/// An MCP tool call that has not finished yet.
ActiveMcpToolCall {
call_id: String,
- /// `server.tool` fully-qualified name so we can show a concise label
- fq_tool_name: String,
- /// Formatted invocation that mirrors the `$ cmd ...` style of exec
- /// commands. We keep this around so the completed state can reuse the
- /// exact same text without re-formatting.
- invocation: String,
+ /// Formatted line that shows the command name and arguments
+ invocation: Line<'static>,
start: Instant,
view: TextBlock,
},
/// Completed MCP tool call where we show the result serialized as JSON.
- CompletedMcpToolCallWithTextOutput { view: TextBlock },
+ CompletedMcpToolCall { view: TextBlock },
/// Completed MCP tool call where the result is an image.
/// Admittedly, [mcp_types::CallToolResult] can have multiple content types,
@@ -289,8 +287,6 @@ impl HistoryCell {
tool: String,
arguments: Option<serde_json::Value>,
) -> Self {
- let fq_tool_name = format!("{server}.{tool}");
-
// Format the arguments as compact JSON so they roughly fit on one
// line. If there are no arguments we keep it empty so the invocation
// mirrors a function-style call.
@@ -302,29 +298,30 @@ impl HistoryCell {
})
.unwrap_or_default();
- let invocation = if args_str.is_empty() {
- format!("{fq_tool_name}()")
- } else {
- format!("{fq_tool_name}({args_str})")
- };
+ let invocation_spans = vec![
+ Span::styled(server, Style::default().fg(Color::Blue)),
+ Span::raw("."),
+ Span::styled(tool, Style::default().fg(Color::Blue)),
+ Span::raw("("),
+ Span::styled(args_str, Style::default().fg(Color::Gray)),
+ Span::raw(")"),
+ ];
+ let invocation = Line::from(invocation_spans);
let start = Instant::now();
let title_line = Line::from(vec!["tool".magenta(), " running...".dim()]);
- let lines: Vec<Line<'static>> = vec![
- title_line,
- Line::from(format!("$ {invocation}")),
- Line::from(""),
- ];
+ let lines: Vec<Line<'static>> = vec![title_line, invocation.clone(), Line::from("")];
HistoryCell::ActiveMcpToolCall {
call_id,
- fq_tool_name,
invocation,
start,
view: TextBlock::new(lines),
}
}
+ /// If the first content is an image, return a new cell with the image.
+ /// TODO(rgwood-dd): Handle images properly even if they're not the first result.
fn try_new_completed_mcp_tool_call_with_image_output(
result: &Result<mcp_types::CallToolResult, String>,
) -> Option<Self> {
@@ -370,8 +367,8 @@ impl HistoryCell {
}
pub(crate) fn new_completed_mcp_tool_call(
- fq_tool_name: String,
- invocation: String,
+ num_cols: u16,
+ invocation: Line<'static>,
start: Instant,
success: bool,
result: Result<mcp_types::CallToolResult, String>,
@@ -384,36 +381,70 @@ impl HistoryCell {
let status_str = if success { "success" } else { "failed" };
let title_line = Line::from(vec![
"tool".magenta(),
- format!(" {fq_tool_name} ({status_str}, duration: {})", duration).dim(),
+ " ".into(),
+ if success {
+ status_str.green()
+ } else {
+ status_str.red()
+ },
+ format!(", duration: {duration}").gray(),
]);
let mut lines: Vec<Line<'static>> = Vec::new();
lines.push(title_line);
- lines.push(Line::from(format!("$ {invocation}")));
-
- // Convert result into serde_json::Value early so we don't have to
- // worry about lifetimes inside the match arm.
- let result_val = result.map(|r| {
- serde_json::to_value(r)
- .unwrap_or_else(|_| serde_json::Value::String("<serialization error>".into()))
- });
-
- if let Ok(res_val) = result_val {
- let json_pretty =
- serde_json::to_string_pretty(&res_val).unwrap_or_else(|_| res_val.to_string());
- let mut iter = json_pretty.lines();
- for raw in iter.by_ref().take(TOOL_CALL_MAX_LINES) {
- lines.push(Line::from(raw.to_string()).dim());
+ lines.push(invocation);
+
+ match result {
+ Ok(mcp_types::CallToolResult { content, .. }) => {
+ if !content.is_empty() {
+ lines.push(Line::from(""));
+
+ for tool_call_result in content {
+ let line_text = match tool_call_result {
+ mcp_types::CallToolResultContent::TextContent(text) => {
+ format_and_truncate_tool_result(
+ &text.text,
+ TOOL_CALL_MAX_LINES,
+ num_cols as usize,
+ )
+ }
+ mcp_types::CallToolResultContent::ImageContent(_) => {
+ // TODO show images even if they're not the first result, will require a refactor of `CompletedMcpToolCall`
+ "<image content>".to_string()
+ }
+ mcp_types::CallToolResultContent::AudioContent(_) => {
+ "<audio content>".to_string()
+ }
+ mcp_types::CallToolResultContent::EmbeddedResource(resource) => {
+ let uri = match resource.resource {
+ EmbeddedResourceResource::TextResourceContents(text) => {
+ text.uri
+ }
+ EmbeddedResourceResource::BlobResourceContents(blob) => {
+ blob.uri
+ }
+ };
+ format!("embedded resource: {uri}")
+ }
+ };
+ lines.push(Line::styled(line_text, Style::default().fg(Color::Gray)));
+ }
+ }
+
+ lines.push(Line::from(""));
}
- let remaining = iter.count();
- if remaining > 0 {
- lines.push(Line::from(format!("... {} additional lines", remaining)).dim());
+ Err(e) => {
+ lines.push(Line::from(vec![
+ Span::styled(
+ "Error: ",
+ Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
+ ),
+ Span::raw(e),
+ ]));
}
- }
-
- lines.push(Line::from(""));
+ };
- HistoryCell::CompletedMcpToolCallWithTextOutput {
+ HistoryCell::CompletedMcpToolCall {
view: TextBlock::new(lines),
}
}
@@ -520,7 +551,7 @@ impl CellWidget for HistoryCell {
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
| HistoryCell::CompletedExecCommand { view }
- | HistoryCell::CompletedMcpToolCallWithTextOutput { view }
+ | HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::ActiveExecCommand { view, .. }
| HistoryCell::ActiveMcpToolCall { view, .. } => view.height(width),
@@ -541,7 +572,7 @@ impl CellWidget for HistoryCell {
| HistoryCell::ErrorEvent { view }
| HistoryCell::SessionInfo { view }
| HistoryCell::CompletedExecCommand { view }
- | HistoryCell::CompletedMcpToolCallWithTextOutput { view }
+ | HistoryCell::CompletedMcpToolCall { view }
| HistoryCell::PendingPatch { view }
| HistoryCell::ActiveExecCommand { view, .. }
| HistoryCell::ActiveMcpToolCall { view, .. } => {
diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs
index df85673ef1..535168e324 100644
--- a/codex-rs/tui/src/lib.rs
+++ b/codex-rs/tui/src/lib.rs
@@ -34,6 +34,7 @@ mod scroll_event_helper;
mod slash_command;
mod status_indicator_widget;
mod text_block;
+mod text_formatting;
mod tui;
mod user_approval_widget;
diff --git a/codex-rs/tui/src/text_formatting.rs b/codex-rs/tui/src/text_formatting.rs
new file mode 100644
index 0000000000..e79fbf7a08
--- /dev/null
+++ b/codex-rs/tui/src/text_formatting.rs
@@ -0,0 +1,268 @@
+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() {
+ if let Some(last_ch) = result.chars().last() {
+ if (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 {
+ #![allow(clippy::unwrap_used)]
+ 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""#);
+ }
+}
Review Comments
codex-rs/tui/Cargo.toml
- Created: 2025-06-03 19:00:18 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124676784
@@ -52,6 +52,7 @@ tui-input = "0.11.1"
tui-markdown = "0.3.3"
tui-textarea = "0.7.0"
uuid = "1"
+unicode-segmentation = "1.12.0"
nit: alpha sort?
codex-rs/tui/src/history_cell.rs
- Created: 2025-06-03 19:01:06 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124678022
@@ -149,25 +145,13 @@ impl HistoryCell {
]),
];
- let mut entries = vec![
+ let entries = vec![
("workdir", config.cwd.display().to_string()),
("model", config.model.clone()),
("provider", config.model_provider_id.clone()),
("approval", format!("{:?}", config.approval_policy)),
("sandbox", format!("{:?}", config.sandbox_policy)),
];
- if config.model_provider.wire_api == WireApi::Responses
bad rebase maybe? did you mean to remove this?
- Created: 2025-06-03 19:08:28 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124692532
@@ -728,3 +741,53 @@ fn ensure_image_cache(
height_rows
}
+
+/// Truncate a tool result to fit within the given width. If the text is valid JSON, we format it in a compact way before truncating
+fn format_and_truncate_tool_result(text: &str, width: u16) -> String {
+ // Work out the maximum number of graphemes we can display for a result given the current terminal width.
+ // 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 = (TOOL_CALL_MAX_LINES * width as usize).saturating_sub(TOOL_CALL_MAX_LINES);
+
+ // try to parse the text as json
+ let json = serde_json::from_str::<serde_json::Value>(text);
+ if let Ok(json) = json {
+ let json_pretty = serde_json::to_string_pretty(&json).unwrap_or_else(|_| json.to_string());
Hmm, I guess
PrettyFormatterisn't configurable to support what you're trying to do here?https://docs.rs/serde_json/1.0.140/serde_json/ser/struct.PrettyFormatter.html
- Created: 2025-06-03 19:21:27 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124724203
@@ -370,8 +353,8 @@ impl HistoryCell {
}
pub(crate) fn new_completed_mcp_tool_call(
- fq_tool_name: String,
- invocation: String,
+ terminal_width: u16,
Maybe
num_colsis a more appropriate name? Because the terminal is technically a bit wider because of borders and whatnot, right?
- Created: 2025-06-03 19:23:42 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124727415
@@ -728,3 +741,53 @@ fn ensure_image_cache(
height_rows
}
+
+/// Truncate a tool result to fit within the given width. If the text is valid JSON, we format it in a compact way before truncating
+fn format_and_truncate_tool_result(text: &str, width: u16) -> String {
Could you please add a couple of unit tests for these functions? Or maybe move them into a helper file (
truncate.rs?) since this file is getting a bit long...
codex-rs/tui/src/text_formatting.rs
- Created: 2025-06-03 21:20:13 UTC | Link: https://github.com/openai/codex/pull/1211#discussion_r2124948153
@@ -0,0 +1,267 @@
+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 => {
to_string_pretty()should never include a literal\ror\n, should it?