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
This commit is contained in:
Dhanji R. Prasanna
2026-02-05 20:18:30 +11:00
parent b2fbcf33d0
commit 30627bce97
4 changed files with 272 additions and 40 deletions

View File

@@ -1,5 +1,5 @@
# Workspace Memory # Workspace Memory
> Updated: 2026-02-05T14:30:00Z | Size: ~19k chars > Updated: 2026-02-05T09:15:01Z | Size: 17.9k chars
### Remember Tool Wiring ### Remember Tool Wiring
- `crates/g3-core/src/tools/memory.rs` [0..5000] - `execute_remember()`, `get_memory_path()`, `merge_memory()` - `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` **State**: `.g3/sdlc/pipeline.json`
**CLI**: `studio sdlc run [-c N]`, `studio sdlc status`, `studio sdlc reset` **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

View File

@@ -24,6 +24,7 @@ mod template;
mod completion; mod completion;
mod project; mod project;
mod input_formatter; mod input_formatter;
mod terminal_width;
use anyhow::Result; use anyhow::Result;
use std::path::PathBuf; use std::path::PathBuf;

View File

@@ -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: "…<partial_path>/<filename>"
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<char> = 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));
}
}

View File

@@ -1,6 +1,7 @@
use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state, ToolParsingHint}; use crate::filter_json::{filter_json_tool_calls, reset_json_tool_state, ToolParsingHint};
use crate::display::{shorten_path, shorten_paths_in_command}; use crate::display::{shorten_path, shorten_paths_in_command};
use crate::streaming_markdown::StreamingMarkdownFormatter; 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 g3_core::ui_writer::UiWriter;
use std::io::{self, Write}; use std::io::{self, Write};
use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU8, Ordering}}; use std::sync::{Arc, Mutex, atomic::{AtomicBool, AtomicU8, Ordering}};
@@ -316,6 +317,10 @@ impl UiWriter for ConsoleUiWriter {
} else { } else {
TOOL_COLOR_NORMAL_BOLD 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() { if let Some(tool_name) = self.current_tool_name.lock().unwrap().as_ref() {
let args = self.current_tool_args.lock().unwrap(); 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) // Shorten paths in the value (handles both file paths and shell commands)
let shortened = shorten_paths_in_command(first_line, workspace_ref, project_ref); let shortened = shorten_paths_in_command(first_line, workspace_ref, project_ref);
// Truncate long values for display (after shortening) // Calculate available width for the value
let display_value = if shortened.chars().count() > 80 { // Header format: "┌─<tool_color> <tool_name><reset><magenta> | <value><suffix><reset>"
// Use char_indices to safely truncate at character boundary // Prefix overhead: "┌─" (2) + tool_name + " | " (3) = 5 + tool_name.len()
let truncate_at = shortened // For shell: " ● <tool_name> | " = ~17 chars overhead
.char_indices() let is_shell_tool = tool_name == "shell";
.nth(77) let prefix_overhead = if is_shell_tool { 17 } else { 5 + tool_name.len() };
.map(|(i, _)| i) let available_for_value = term_width.saturating_sub(prefix_overhead);
.unwrap_or(shortened.len());
format!("{}...", &shortened[..truncate_at]) // 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 { } else {
shortened compress_path(&shortened, available_for_value)
}; };
// Add range information for read_file tool calls // Add range information for read_file tool calls
@@ -404,12 +411,13 @@ impl UiWriter for ConsoleUiWriter {
} }
fn update_tool_output_line(&self, line: &str) { fn update_tool_output_line(&self, line: &str) {
// Truncate long lines to prevent terminal wrapping issues // Get terminal width and calculate available space for content
// When lines wrap, the cursor-up escape code only moves up one visual line // Prefix is "│ " (3 chars) for normal tools or " └─ " (6 chars) for shell
const MAX_LINE_WIDTH: usize = 120;
let mut current_line = self.current_output_line.lock().unwrap(); let mut current_line = self.current_output_line.lock().unwrap();
let mut line_printed = self.output_line_printed.lock().unwrap(); let mut line_printed = self.output_line_printed.lock().unwrap();
let is_shell = *self.is_shell_compact.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 we've already printed a line, clear it first
if *line_printed { if *line_printed {
@@ -422,13 +430,8 @@ impl UiWriter for ConsoleUiWriter {
} }
} }
// Truncate line if needed to prevent wrapping // Clip line to fit terminal width
let display_line = if line.chars().count() > MAX_LINE_WIDTH { let display_line = clip_line(line, max_content_width);
let truncated: String = line.chars().take(MAX_LINE_WIDTH - 3).collect();
format!("{}...", truncated)
} else {
line.to_string()
};
// Use different prefix for shell (└─) vs other tools (│) // Use different prefix for shell (└─) vs other tools (│)
if is_shell { if is_shell {
@@ -449,7 +452,9 @@ impl UiWriter for ConsoleUiWriter {
if line.starts_with("📝 TODO list:") { if line.starts_with("📝 TODO list:") {
return; 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) { 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 args = self.current_tool_args.lock().unwrap();
let is_agent_mode = self.hint_state.is_agent_mode.load(Ordering::Relaxed); 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) // Get file path (for file operation tools)
let file_path = args let file_path = args
.iter() .iter()
@@ -511,13 +519,11 @@ impl UiWriter for ConsoleUiWriter {
if let Some(first_search) = searches.as_array().and_then(|arr| arr.first()) { 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 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("?"); let name = first_search.get("name").and_then(|v| v.as_str()).unwrap_or("?");
// Truncate name if too long // Calculate available width for search name
let display_name = if name.len() > 30 { // Format: " ● code_search | lang:"name" | summary | tokens ◉ time"
let truncate_at = name.char_indices().nth(27).map(|(i, _)| i).unwrap_or(name.len()); // Fixed overhead: ~50 chars + lang (~10) = ~60
format!("{}...", &name[..truncate_at]) let available_for_name = term_width.saturating_sub(60);
} else { let display_name = clip_line(name, available_for_name);
name.to_string()
};
format!("{}:\"{}\"", lang, display_name) format!("{}:\"{}\"", lang, display_name)
} else { } else {
String::new() String::new()
@@ -538,17 +544,14 @@ impl UiWriter for ConsoleUiWriter {
let project_info = self.get_project_info(); let project_info = self.get_project_info();
let project_ref = project_info.as_ref().map(|(p, n)| (p.as_path(), n.as_str())); 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); let shortened = shorten_path(file_path, workspace.as_deref(), project_ref);
if shortened.chars().count() > 60 { // Calculate available width for path
let truncate_at = shortened // Format: " ● tool_name | path [range] | summary | tokens ◉ time"
.char_indices() // Fixed overhead: " ● " (3) + tool_name padded (11) + " | " (3) + " | " (3) + summary (~15) + " | " (3) + tokens+time (~15) = ~53
.nth(57) // Plus range_suffix length (variable, ~10-15 chars if present)
.map(|(i, _)| i) let fixed_overhead = 53;
.unwrap_or(shortened.len()); let available_for_path = term_width.saturating_sub(fixed_overhead);
format!("{}...", &shortened[..truncate_at]) compress_path(&shortened, available_for_path)
} else {
shortened
}
}; };
// Build range suffix for read_file // Build range suffix for read_file