From 30627bce97c04e027f8e9e5e8e37a259f8909786 Mon Sep 17 00:00:00 2001 From: "Dhanji R. Prasanna" Date: Thu, 5 Feb 2026 20:18:30 +1100 Subject: [PATCH] feat(cli): make tool output responsive to terminal width - Add terminal_width module with get_terminal_width(), clip_line(), compress_path(), and compress_command() utilities - Update ConsoleUiWriter to use dynamic terminal width for all tool output - Tool output lines are clipped to fit without wrapping - Tool headers use semantic compression (paths preserve filename, commands clip from right) - 4-character right margin for visual clarity - Minimum 40 columns, default 80 when terminal size unavailable - All truncation is UTF-8 safe (char counting, not byte slicing) - Add 13 unit tests for terminal width utilities --- analysis/memory.md | 17 ++- crates/g3-cli/src/lib.rs | 1 + crates/g3-cli/src/terminal_width.rs | 213 ++++++++++++++++++++++++++++ crates/g3-cli/src/ui_writer_impl.rs | 81 ++++++----- 4 files changed, 272 insertions(+), 40 deletions(-) create mode 100644 crates/g3-cli/src/terminal_width.rs diff --git a/analysis/memory.md b/analysis/memory.md index 202ad73..6ed9bff 100644 --- a/analysis/memory.md +++ b/analysis/memory.md @@ -1,5 +1,5 @@ # Workspace Memory -> Updated: 2026-02-05T14:30:00Z | Size: ~19k chars +> Updated: 2026-02-05T09:15:01Z | Size: 17.9k chars ### Remember Tool Wiring - `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()` @@ -323,3 +323,18 @@ Orchestrates 7 agents in sequence for codebase maintenance. **State**: `.g3/sdlc/pipeline.json` **CLI**: `studio sdlc run [-c N]`, `studio sdlc status`, `studio sdlc reset` + +### Terminal Width Responsive Output +Makes tool output responsive to terminal width - no line wrapping, with 4-char right margin. + +- `crates/g3-cli/src/terminal_width.rs` + - `get_terminal_width()` [21..28] - returns usable width (terminal - 4 margin), min 40, default 80 + - `clip_line()` [33..44] - clips line with "…" ellipsis, UTF-8 safe + - `compress_path()` [53..96] - preserves filename, truncates dirs from left with "…" + - `compress_command()` [101..103] - clips command from right with "…" + - `available_width_after_prefix()` [115..117] - helper for prefixed lines +- `crates/g3-cli/src/ui_writer_impl.rs` + - `update_tool_output_line()` [407..445] - uses clip_line() with dynamic width + - `print_tool_output_line()` [447..454] - uses clip_line() for output lines + - `print_tool_output_header()` [293..410] - uses compress_path/compress_command + - `print_tool_compact()` [475..635] - width-aware compact tool display \ No newline at end of file diff --git a/crates/g3-cli/src/lib.rs b/crates/g3-cli/src/lib.rs index eccea94..55b8dec 100644 --- a/crates/g3-cli/src/lib.rs +++ b/crates/g3-cli/src/lib.rs @@ -24,6 +24,7 @@ mod template; mod completion; mod project; mod input_formatter; +mod terminal_width; use anyhow::Result; use std::path::PathBuf; diff --git a/crates/g3-cli/src/terminal_width.rs b/crates/g3-cli/src/terminal_width.rs new file mode 100644 index 0000000..a590bf6 --- /dev/null +++ b/crates/g3-cli/src/terminal_width.rs @@ -0,0 +1,213 @@ +//! Terminal width utilities for responsive output formatting. +//! +//! Provides functions to get terminal width and clip/compress content +//! to fit within the available space without wrapping. + +use crossterm::terminal; + +/// Right margin to leave for visual clarity and elegance. +const RIGHT_MARGIN: usize = 4; + +/// Minimum usable terminal width (below this, we don't compress further). +const MIN_WIDTH: usize = 40; + +/// Default terminal width when size cannot be determined. +const DEFAULT_WIDTH: usize = 80; + +/// Get the usable terminal width (total width minus right margin). +/// +/// Returns the terminal width minus a 4-character right margin for clarity. +/// Falls back to 80 columns (76 usable) if terminal size cannot be determined. +/// Enforces a minimum usable width of 40 characters. +pub fn get_terminal_width() -> usize { + let width = terminal::size() + .map(|(w, _)| w as usize) + .unwrap_or(DEFAULT_WIDTH); + + // Subtract margin, but ensure minimum usable width + width.saturating_sub(RIGHT_MARGIN).max(MIN_WIDTH) +} + +/// Clip a line to fit within the given width, adding ellipsis if truncated. +/// +/// Uses UTF-8 safe character counting to avoid panics on multi-byte characters. +pub fn clip_line(line: &str, max_width: usize) -> String { + let char_count = line.chars().count(); + + if char_count <= max_width { + return line.to_string(); + } + + // Need to truncate: leave room for "…" (1 char) + let truncate_at = max_width.saturating_sub(1); + let truncated: String = line.chars().take(truncate_at).collect(); + format!("{}…", truncated) +} + +/// Compress a file path to fit within the given width. +/// +/// Preserves the filename and as much of the path as possible. +/// Truncates parent directories from the left, replacing with "…". +/// +/// Examples: +/// - Full: `/Users/dhanji/src/g3/crates/g3-cli/src/ui_writer_impl.rs` +/// - Compressed: `…g3-cli/src/ui_writer_impl.rs` +/// - More compressed: `…/ui_writer_impl.rs` +pub fn compress_path(path: &str, max_width: usize) -> String { + let char_count = path.chars().count(); + + if char_count <= max_width { + return path.to_string(); + } + + // Extract filename (last component) + let filename = path.rsplit('/').next().unwrap_or(path); + let filename_len = filename.chars().count(); + + // If filename alone is too long, truncate it + if filename_len + 1 >= max_width { + // Just show truncated filename with ellipsis + return clip_line(filename, max_width); + } + + // Try to fit as much of the path as possible + // Format: "…/" + let available_for_path = max_width.saturating_sub(filename_len + 2); // 1 for "…", 1 for "/" + + if available_for_path == 0 { + return format!("…/{}", filename); + } + + // Get the directory part (everything before filename) + let dir_part = if let Some(pos) = path.rfind('/') { + &path[..pos] + } else { + return path.to_string(); // No directory separator + }; + + // Take characters from the end of the directory path + let dir_chars: Vec = dir_part.chars().collect(); + let dir_len = dir_chars.len(); + + if dir_len <= available_for_path { + return path.to_string(); // Shouldn't happen, but safety check + } + + // Take the last `available_for_path` characters from the directory + let start_idx = dir_len.saturating_sub(available_for_path); + let partial_dir: String = dir_chars[start_idx..].iter().collect(); + + format!("…{}/{}", partial_dir, filename) +} + +/// Compress a shell command to fit within the given width. +/// +/// Preserves the command name and as much of the arguments as possible. +/// Truncates from the right, adding "…" at the end. +pub fn compress_command(command: &str, max_width: usize) -> String { + clip_line(command, max_width) +} + +/// Calculate available width for content after accounting for a prefix. +/// +/// This is useful for tool output lines that have a fixed prefix like "│ ". +#[allow(dead_code)] // Utility function for future use +pub fn available_width_after_prefix(prefix_width: usize) -> usize { + get_terminal_width().saturating_sub(prefix_width) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clip_line_short() { + let line = "hello world"; + assert_eq!(clip_line(line, 80), "hello world"); + } + + #[test] + fn test_clip_line_exact() { + let line = "hello"; + assert_eq!(clip_line(line, 5), "hello"); + } + + #[test] + fn test_clip_line_truncate() { + let line = "hello world this is a long line"; + assert_eq!(clip_line(line, 15), "hello world th…"); + } + + #[test] + fn test_clip_line_unicode() { + let line = "héllo wörld 你好"; + let clipped = clip_line(line, 10); + assert_eq!(clipped.chars().count(), 10); + assert!(clipped.ends_with('…')); + } + + #[test] + fn test_clip_line_empty() { + assert_eq!(clip_line("", 80), ""); + } + + #[test] + fn test_compress_path_short() { + let path = "src/main.rs"; + assert_eq!(compress_path(path, 80), "src/main.rs"); + } + + #[test] + fn test_compress_path_long() { + let path = "/Users/dhanji/src/g3/crates/g3-cli/src/ui_writer_impl.rs"; + let compressed = compress_path(path, 40); + assert!(compressed.chars().count() <= 40); + assert!(compressed.ends_with("ui_writer_impl.rs")); + assert!(compressed.starts_with('…')); + } + + #[test] + fn test_compress_path_preserves_filename() { + let path = "/very/long/path/to/some/deeply/nested/file.rs"; + let compressed = compress_path(path, 20); + assert!(compressed.contains("file.rs")); + } + + #[test] + fn test_compress_path_very_narrow() { + let path = "/path/to/extremely_long_filename_that_exceeds_width.rs"; + let compressed = compress_path(path, 15); + assert!(compressed.chars().count() <= 15); + assert!(compressed.ends_with('…')); + } + + #[test] + fn test_compress_command_short() { + let cmd = "ls -la"; + assert_eq!(compress_command(cmd, 80), "ls -la"); + } + + #[test] + fn test_compress_command_long() { + let cmd = "rg 'pattern' --type rust -l | head -20 | sort"; + let compressed = compress_command(cmd, 30); + assert!(compressed.chars().count() <= 30); + assert!(compressed.starts_with("rg 'pattern'")); + assert!(compressed.ends_with('…')); + } + + #[test] + fn test_get_terminal_width_returns_reasonable_value() { + let width = get_terminal_width(); + // Should be at least MIN_WIDTH + assert!(width >= MIN_WIDTH); + // Should be reasonable (not absurdly large) + assert!(width < 1000); + } + + #[test] + fn test_available_width_after_prefix() { + let width = available_width_after_prefix(3); // e.g., "│ " + assert!(width >= MIN_WIDTH.saturating_sub(3)); + } +} diff --git a/crates/g3-cli/src/ui_writer_impl.rs b/crates/g3-cli/src/ui_writer_impl.rs index 4551606..a3f8b7c 100644 --- a/crates/g3-cli/src/ui_writer_impl.rs +++ b/crates/g3-cli/src/ui_writer_impl.rs @@ -1,6 +1,7 @@ use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state, ToolParsingHint}; use crate::display::{shorten_path, shorten_paths_in_command}; use crate::streaming_markdown::StreamingMarkdownFormatter; +use crate::terminal_width::{get_terminal_width, clip_line, compress_path, compress_command}; use g3_core::ui_writer::UiWriter; use std::io::{self, Write}; use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU8, Ordering}}; @@ -316,6 +317,10 @@ impl UiWriter for ConsoleUiWriter { } else { TOOL_COLOR_NORMAL_BOLD }; + + // Get terminal width for responsive formatting + let term_width = get_terminal_width(); + if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() { let args = self.current_tool_args.lock().unwrap(); @@ -341,17 +346,19 @@ impl UiWriter for ConsoleUiWriter { // Shorten paths in the value (handles both file paths and shell commands) let shortened = shorten_paths_in_command(first_line, workspace_ref, project_ref); - // Truncate long values for display (after shortening) - let display_value = if shortened.chars().count() > 80 { - // Use char_indices to safely truncate at character boundary - let truncate_at = shortened - .char_indices() - .nth(77) - .map(|(i, _)| i) - .unwrap_or(shortened.len()); - format!("{}...", &shortened[..truncate_at]) + // Calculate available width for the value + // Header format: "┌─ | " + // Prefix overhead: "┌─" (2) + tool_name + " | " (3) = 5 + tool_name.len() + // For shell: " ● | " = ~17 chars overhead + let is_shell_tool = tool_name == "shell"; + let prefix_overhead = if is_shell_tool { 17 } else { 5 + tool_name.len() }; + let available_for_value = term_width.saturating_sub(prefix_overhead); + + // Compress path or command to fit available width + let display_value = if is_shell_tool || tool_name == "background_process" { + compress_command(&shortened, available_for_value) } else { - shortened + compress_path(&shortened, available_for_value) }; // Add range information for read_file tool calls @@ -404,12 +411,13 @@ impl UiWriter for ConsoleUiWriter { } fn update_tool_output_line(&self, line: &str) { - // Truncate long lines to prevent terminal wrapping issues - // When lines wrap, the cursor-up escape code only moves up one visual line - const MAX_LINE_WIDTH: usize = 120; + // Get terminal width and calculate available space for content + // Prefix is "│ " (3 chars) for normal tools or " └─ " (6 chars) for shell let mut current_line = self.current_output_line.lock().unwrap(); let mut line_printed = self.output_line_printed.lock().unwrap(); let is_shell = *self.is_shell_compact.lock().unwrap(); + let prefix_width = if is_shell { 6 } else { 3 }; + let max_content_width = get_terminal_width().saturating_sub(prefix_width); // If we've already printed a line, clear it first if *line_printed { @@ -422,13 +430,8 @@ impl UiWriter for ConsoleUiWriter { } } - // Truncate line if needed to prevent wrapping - let display_line = if line.chars().count() > MAX_LINE_WIDTH { - let truncated: String = line.chars().take(MAX_LINE_WIDTH - 3).collect(); - format!("{}...", truncated) - } else { - line.to_string() - }; + // Clip line to fit terminal width + let display_line = clip_line(line, max_content_width); // Use different prefix for shell (└─) vs other tools (│) if is_shell { @@ -449,7 +452,9 @@ impl UiWriter for ConsoleUiWriter { if line.starts_with("📝 TODO list:") { return; } - println!("│ \x1b[2m{}\x1b[0m", line); + // Clip line to fit terminal width (prefix "│ " is 3 chars) + let max_content_width = get_terminal_width().saturating_sub(3); + println!("│ \x1b[2m{}\x1b[0m", clip_line(line, max_content_width)); } fn print_tool_output_summary(&self, count: usize) { @@ -490,6 +495,9 @@ impl UiWriter for ConsoleUiWriter { let args = self.current_tool_args.lock().unwrap(); let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed); + // Get terminal width for responsive formatting + let term_width = get_terminal_width(); + // Get file path (for file operation tools) let file_path = args .iter() @@ -511,13 +519,11 @@ impl UiWriter for ConsoleUiWriter { if let Some(first_search) = searches.as_array().and_then(|arr| arr.first()) { let lang = first_search.get("language").and_then(|v| v.as_str()).unwrap_or("?"); let name = first_search.get("name").and_then(|v| v.as_str()).unwrap_or("?"); - // Truncate name if too long - let display_name = if name.len() > 30 { - let truncate_at = name.char_indices().nth(27).map(|(i, _)| i).unwrap_or(name.len()); - format!("{}...", &name[..truncate_at]) - } else { - name.to_string() - }; + // Calculate available width for search name + // Format: " ● code_search | lang:"name" | summary | tokens ◉ time" + // Fixed overhead: ~50 chars + lang (~10) = ~60 + let available_for_name = term_width.saturating_sub(60); + let display_name = clip_line(name, available_for_name); format!("{}:\"{}\"", lang, display_name) } else { String::new() @@ -538,17 +544,14 @@ impl UiWriter for ConsoleUiWriter { let project_info = self.get_project_info(); let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str())); let shortened = shorten_path(file_path, workspace.as_deref(), project_ref); - - if shortened.chars().count() > 60 { - let truncate_at = shortened - .char_indices() - .nth(57) - .map(|(i, _)| i) - .unwrap_or(shortened.len()); - format!("{}...", &shortened[..truncate_at]) - } else { - shortened - } + + // Calculate available width for path + // Format: " ● tool_name | path [range] | summary | tokens ◉ time" + // Fixed overhead: " ● " (3) + tool_name padded (11) + " | " (3) + " | " (3) + summary (~15) + " | " (3) + tokens+time (~15) = ~53 + // Plus range_suffix length (variable, ~10-15 chars if present) + let fixed_overhead = 53; + let available_for_path = term_width.saturating_sub(fixed_overhead); + compress_path(&shortened, available_for_path) }; // Build range suffix for read_file