Compare commits

...
2 Commits
6 changed files with 186 additions and 25 deletions
Generated
+1 -1
View File
@@ -21,7 +21,7 @@ dependencies = [
[[package]] [[package]]
name = "agent-manager" name = "agent-manager"
version = "0.2.9" version = "0.2.11"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"chrono", "chrono",
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "agent-manager" name = "agent-manager"
version = "0.2.9" version = "0.2.11"
edition = "2021" edition = "2021"
description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog." description = "Manage local AI coding agents: list, install, start, stop, update — with automatic dependency handling and a YAML-driven catalog."
license = "MIT" license = "MIT"
@@ -36,7 +36,7 @@ which = "7"
zip = "0.6" zip = "0.6"
[target.'cfg(windows)'.dependencies] [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] [profile.release]
lto = "thin" lto = "thin"
+3 -2
View File
@@ -163,9 +163,10 @@ pub fn ls_table(path: &Path, color: bool) -> Result<DataTable> {
}) })
} }
/// 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<String> { pub fn render_ls(path: &Path, color: bool) -> Result<String> {
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. /// Completion candidates for a path fragment typed after cd or ls.
+36
View File
@@ -239,6 +239,42 @@ pub fn print_json<T: Serialize>(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<usize> {
if let Ok(v) = std::env::var("AM_TABLE_WIDTH") {
if let Ok(w) = v.trim().parse::<usize>() {
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::<usize>() {
return Some(w);
}
}
None
}
}
fn strip_ansi(s: &str) -> String { fn strip_ansi(s: &str) -> String {
let mut out = String::new(); let mut out = String::new();
let mut chars = s.chars(); let mut chars = s.chars();
+30 -3
View File
@@ -354,6 +354,7 @@ pub fn banner_box(
version: &str, version: &str,
agents: usize, agents: usize,
installed: usize, installed: usize,
external: usize,
session: &ShellSession, session: &ShellSession,
session_id: &str, session_id: &str,
cwd: &str, cwd: &str,
@@ -425,7 +426,7 @@ pub fn banner_box(
rows.push(blank()); rows.push(blank());
rows.push(inner(dim( rows.push(inner(dim(
&format!( &format!(
"am v{version} · {agents} agents · {installed} installed · shell {} · /help for commands", "am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · /help for commands",
session.current.name session.current.name
), ),
color, color,
@@ -449,6 +450,7 @@ pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
env!("CARGO_PKG_VERSION"), env!("CARGO_PKG_VERSION"),
agent_count(app), agent_count(app),
installed_count(app), installed_count(app),
external_count(app),
session, session,
session_id, session_id,
&current_dir_string(), &current_dir_string(),
@@ -484,8 +486,9 @@ fn print_status(app: &App, session: &ShellSession, started: Instant) {
); );
let counts = dim( let counts = dim(
&format!( &format!(
"{} agents · {} installed", "{} agents · {} external · {} managed",
agent_count(app), agent_count(app),
external_count(app),
installed_count(app) installed_count(app)
), ),
color, color,
@@ -609,6 +612,25 @@ fn installed_count(app: &App) -> usize {
.unwrap_or(0) .unwrap_or(0)
} }
/// Number of external agents (installed outside agent-manager) detected on
/// the PATH — the same definition used by the "external" status in am list.
fn external_count(app: &App) -> usize {
let probe = crate::probe::detect_externals(app);
let managed: std::collections::BTreeSet<String> = app
.state
.load()
.ok()
.map(|s| s.installed.keys().cloned().collect())
.unwrap_or_default();
app.catalog
.agents()
.iter()
.filter(|a| !a.hidden && a.installable)
.filter(|a| !managed.contains(&a.name))
.filter(|a| probe.path_of(&a.name).is_some())
.count()
}
fn current_dir_string() -> String { fn current_dir_string() -> String {
std::env::current_dir() std::env::current_dir()
.map(|p| p.display().to_string()) .map(|p| p.display().to_string())
@@ -982,8 +1004,10 @@ fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
} }
/// Print a table and remember it as the last result (for where/get). /// 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<DataTable>, table: DataTable) { fn show_table(app: &App, last: &mut Option<DataTable>, 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); *last = Some(table);
} }
@@ -1243,6 +1267,7 @@ mod tests {
"9.9.9", "9.9.9",
65, 65,
3, 3,
12,
&session, &session,
"20260815_123456_abcdef", "20260815_123456_abcdef",
"/home/alice/dev/agent-manager", "/home/alice/dev/agent-manager",
@@ -1256,6 +1281,8 @@ mod tests {
assert!(text.contains("(default)")); assert!(text.contains("(default)"));
assert!(text.contains("bash")); assert!(text.contains("bash"));
assert!(text.contains("/help for commands")); assert!(text.contains("/help for commands"));
assert!(text.contains("12 external"));
assert!(text.contains("3 managed"));
assert!(text.contains("agent-manager")); assert!(text.contains("agent-manager"));
} }
+114 -17
View File
@@ -53,12 +53,19 @@ impl DataTable {
.position(|c| c.eq_ignore_ascii_case(name)) .position(|c| c.eq_ignore_ascii_case(name))
} }
/// Render the boxed Nushell-style table. /// Render the boxed Nushell-style table, fitting max_width columns.
pub fn render(&self, color: bool) -> String { /// 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(); let headers = self.columns.clone();
if headers.is_empty() {
return String::new();
}
// Column 0 is the row index; the data columns follow. // Column 0 is the row index; the data columns follow.
let index_width = self.rows.len().to_string().len().max(1); let index_width = self.rows.len().to_string().len().max(1);
let mut widths: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect(); let mut natural: Vec<usize> = headers.iter().map(|h| visible_len(h)).collect();
let mut cells: Vec<Vec<String>> = Vec::new(); let mut cells: Vec<Vec<String>> = Vec::new();
for (i, row) in self.rows.iter().enumerate() { for (i, row) in self.rows.iter().enumerate() {
let mut line = vec![i.to_string()]; let mut line = vec![i.to_string()];
@@ -66,25 +73,54 @@ impl DataTable {
line.push(cell.text.clone()); line.push(cell.text.clone());
} }
for (c, cell) in line.iter().enumerate().skip(1) { 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); cells.push(line);
} }
let line_width = |ncols: usize, widths: &[usize]| -> usize {
1 + 3 * (ncols + 1) + index_width + widths.iter().sum::<usize>()
};
// Choose the final column set and widths.
let mut data_cols = headers.len();
let mut widths: Vec<usize> = 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<String> = std::iter::once("#".to_string()) let all_headers: Vec<String> = std::iter::once("#".to_string())
.chain(headers.iter().cloned()) .chain(headers.iter().take(data_cols).cloned())
.collect(); .collect();
let mut all_widths = vec![index_width]; let mut all_widths = vec![index_width];
all_widths.extend(widths.iter().cloned()); 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 border = |left: char, mid: char, right: char| -> String {
let mut line = String::new(); let mut line = String::new();
line.push(left); line.push(left);
@@ -99,9 +135,9 @@ impl DataTable {
}; };
let row_line = |row: &[String], bold: bool| -> String { let row_line = |row: &[String], bold: bool| -> String {
let mut line = String::from("│"); 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(' '); line.push(' ');
let cell = pad(c, all_widths[i]); let cell = fit_cell(c, all_widths[i]);
let rendered = if bold { let rendered = if bold {
cell.bold().to_string() cell.bold().to_string()
} else { } else {
@@ -231,6 +267,33 @@ pub fn get_column(table: &DataTable, field: &str) -> Result<DataTable> {
}) })
} }
/// 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 { fn strip_ansi(s: &str) -> String {
let mut out = String::new(); let mut out = String::new();
let mut chars = s.chars(); let mut chars = s.chars();
@@ -318,11 +381,45 @@ mod tests {
#[test] #[test]
fn render_is_aligned() { fn render_is_aligned() {
let t = sample(); let t = sample();
let text = t.render(false); let text = t.render(false, 200);
assert!(text.contains("╭")); assert!(text.contains("╭"));
assert!(text.contains("name")); assert!(text.contains("name"));
assert!(text.contains("am.exe")); assert!(text.contains("am.exe"));
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect(); let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned:\n{text}"); 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}["));
}
} }