diff --git a/Cargo.lock b/Cargo.lock index 37e7e26..8f4f2d4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,7 +21,7 @@ dependencies = [ [[package]] name = "agent-manager" -version = "0.2.9" +version = "0.2.10" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 963c91b..61d44f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "agent-manager" -version = "0.2.9" +version = "0.2.10" edition = "2021" description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." license = "MIT" @@ -36,7 +36,7 @@ which = "7" zip = "0.6" [target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp"] } +windows-sys = { version = "0.52", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_System_ProcessStatus", "Win32_System_Diagnostics_ToolHelp", "Win32_System_Console"] } [profile.release] lto = "thin" diff --git a/src/nav.rs b/src/nav.rs index 15dfefd..6ec0b52 100644 --- a/src/nav.rs +++ b/src/nav.rs @@ -163,9 +163,10 @@ pub fn ls_table(path: &Path, color: bool) -> Result { }) } -/// Render a Nushell-style boxed table of the entries of a path. +/// Render a Nushell-style boxed table of the entries of a path (unbounded +/// width — used by tests; the REPL renders with the terminal width). pub fn render_ls(path: &Path, color: bool) -> Result { - Ok(ls_table(path, color)?.render(color)) + Ok(ls_table(path, color)?.render(color, 200)) } /// Completion candidates for a path fragment typed after cd or ls. diff --git a/src/output.rs b/src/output.rs index 9763801..c4cb26c 100644 --- a/src/output.rs +++ b/src/output.rs @@ -239,6 +239,42 @@ pub fn print_json(value: &T) { } } +/// Width of the terminal attached to stdout, in columns. Returns None when +/// stdout is not a terminal (e.g. piped). AM_TABLE_WIDTH overrides it, mostly +/// for tests and scripts. +pub fn terminal_width() -> Option { + if let Ok(v) = std::env::var("AM_TABLE_WIDTH") { + if let Ok(w) = v.trim().parse::() { + return Some(w); + } + } + #[cfg(windows)] + { + use std::os::windows::io::AsRawHandle; + use windows_sys::Win32::System::Console::{ + GetConsoleScreenBufferInfo, CONSOLE_SCREEN_BUFFER_INFO, + }; + let handle = std::io::stdout().as_raw_handle() as isize; + let mut info: CONSOLE_SCREEN_BUFFER_INFO = unsafe { std::mem::zeroed() }; + if unsafe { GetConsoleScreenBufferInfo(handle, &mut info) } != 0 { + let width = (info.srWindow.Right - info.srWindow.Left + 1) as usize; + if width > 0 { + return Some(width); + } + } + None + } + #[cfg(not(windows))] + { + if let Ok(v) = std::env::var("COLUMNS") { + if let Ok(w) = v.trim().parse::() { + return Some(w); + } + } + None + } +} + fn strip_ansi(s: &str) -> String { let mut out = String::new(); let mut chars = s.chars(); diff --git a/src/repl.rs b/src/repl.rs index 1214228..33d77c3 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -982,8 +982,10 @@ fn ls_target(rest: &[String]) -> Result { } /// Print a table and remember it as the last result (for where/get). +/// The table is rendered to fit the terminal width so rows never wrap. fn show_table(app: &App, last: &mut Option, table: DataTable) { - print!("{}", table.render(app.color())); + let width = crate::output::terminal_width().unwrap_or(120); + print!("{}", table.render(app.color(), width)); *last = Some(table); } diff --git a/src/tables.rs b/src/tables.rs index feb610b..aa7268a 100644 --- a/src/tables.rs +++ b/src/tables.rs @@ -53,12 +53,19 @@ impl DataTable { .position(|c| c.eq_ignore_ascii_case(name)) } - /// Render the boxed Nushell-style table. - pub fn render(&self, color: bool) -> String { + /// Render the boxed Nushell-style table, fitting max_width columns. + /// When the natural layout is too wide, the first (name) column is + /// truncated with an ellipsis and the trailing columns are dropped if + /// that is still not enough — every row stays on a single line. + pub fn render(&self, color: bool, max_width: usize) -> String { + let max_width = max_width.max(20); let headers = self.columns.clone(); + if headers.is_empty() { + return String::new(); + } // Column 0 is the row index; the data columns follow. let index_width = self.rows.len().to_string().len().max(1); - let mut widths: Vec = headers.iter().map(|h| visible_len(h)).collect(); + let mut natural: Vec = headers.iter().map(|h| visible_len(h)).collect(); let mut cells: Vec> = Vec::new(); for (i, row) in self.rows.iter().enumerate() { let mut line = vec![i.to_string()]; @@ -66,25 +73,54 @@ impl DataTable { line.push(cell.text.clone()); } for (c, cell) in line.iter().enumerate().skip(1) { - widths[c - 1] = widths[c - 1].max(visible_len(cell)); + natural[c - 1] = natural[c - 1].max(visible_len(cell)); } cells.push(line); } + let line_width = |ncols: usize, widths: &[usize]| -> usize { + 1 + 3 * (ncols + 1) + index_width + widths.iter().sum::() + }; + + // Choose the final column set and widths. + let mut data_cols = headers.len(); + let mut widths: Vec = natural.clone(); + loop { + if data_cols == 0 { + break; + } + widths.truncate(data_cols); + if line_width(data_cols, &widths) <= max_width { + // Expand the first column into the remaining slack. + widths[0] += max_width - line_width(data_cols, &widths); + break; + } + // Cap the non-first columns, then give the first what remains. + let mut capped = widths.clone(); + for w in capped.iter_mut().skip(1) { + *w = (*w).min(16); + } + if line_width(data_cols, &capped) <= max_width { + capped[0] += max_width - line_width(data_cols, &capped); + widths = capped; + break; + } + if data_cols == 1 { + // Last resort: shrink the single column to whatever remains + // after the borders and the index column. + let fixed = 1 + 3 * (data_cols + 1) + index_width; + capped[0] = max_width.saturating_sub(fixed).max(1); + widths = capped; + break; + } + data_cols -= 1; + } + let all_headers: Vec = std::iter::once("#".to_string()) - .chain(headers.iter().cloned()) + .chain(headers.iter().take(data_cols).cloned()) .collect(); let mut all_widths = vec![index_width]; all_widths.extend(widths.iter().cloned()); - for (c, w) in all_widths.iter_mut().enumerate() { - if c < all_headers.len() { - *w = (*w).max(visible_len(&all_headers[c])); - } - } - let pad = |s: &str, w: usize| -> String { - let n = visible_len(s); - format!("{s}{}", " ".repeat(w.saturating_sub(n))) - }; let border = |left: char, mid: char, right: char| -> String { let mut line = String::new(); line.push(left); @@ -99,9 +135,9 @@ impl DataTable { }; let row_line = |row: &[String], bold: bool| -> String { let mut line = String::from("│"); - for (i, c) in row.iter().enumerate() { + for (i, c) in row.iter().enumerate().take(all_widths.len()) { line.push(' '); - let cell = pad(c, all_widths[i]); + let cell = fit_cell(c, all_widths[i]); let rendered = if bold { cell.bold().to_string() } else { @@ -231,6 +267,33 @@ pub fn get_column(table: &DataTable, field: &str) -> Result { }) } +/// Pad a cell to its column width, or truncate it (with an ellipsis, +/// preserving any leading color codes) when it is too wide. +fn fit_cell(s: &str, w: usize) -> String { + let n = visible_len(s); + if n <= w { + return format!("{s}{}", " ".repeat(w - n)); + } + let plain = strip_ansi(s); + let mut cut: String = plain.chars().take(w.saturating_sub(1).max(1)).collect(); + cut.push('…'); + if s.contains('') { + let mut prefix = String::new(); + let mut chars = s.chars().peekable(); + while chars.peek() == Some(&'') { + for ch in chars.by_ref() { + prefix.push(ch); + if ch == 'm' { + break; + } + } + } + format!("{prefix}{cut}") + } else { + cut + } +} + fn strip_ansi(s: &str) -> String { let mut out = String::new(); let mut chars = s.chars(); @@ -318,11 +381,45 @@ mod tests { #[test] fn render_is_aligned() { let t = sample(); - let text = t.render(false); + let text = t.render(false, 200); assert!(text.contains("╭")); assert!(text.contains("name")); assert!(text.contains("am.exe")); let widths: Vec = text.lines().map(|l| l.chars().count()).collect(); assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}"); } + + #[test] + fn render_fits_narrow_width() { + // A table whose first column is too long to fit naturally. + let t = DataTable { + columns: vec!["name".into(), "type".into(), "size".into()], + rows: vec![vec![ + Cell::str("a-very-long-file-name-that-overflows.txt"), + Cell::str("file"), + Cell::bytes(512, "512 B".into()), + ]], + }; + // Trailing columns are dropped when too narrow. + let text = t.render(false, 45); + for line in text.lines() { + assert!(line.chars().count() <= 45, "too wide: {line}"); + } + assert!(!text.contains("size")); + // The name column is truncated with an ellipsis at the very end. + let text = t.render(false, 30); + for line in text.lines() { + assert!(line.chars().count() <= 30, "too wide: {line}"); + } + assert!(text.contains("…"), "name should be truncated: +{text}"); + } + + #[test] + fn fit_cell_truncates_and_keeps_color() { + let colored = "\u{1b}[34mverylongname\u{1b}[0m".to_string(); + let out = fit_cell(&colored, 8); + assert_eq!(strip_ansi(&out).chars().count(), 8); + assert!(out.starts_with("\u{1b}[")); + } }