v0.2.14 : aide Nushell, themes de couleurs et cadres blancs
- Aide facon Nushell : help <cmd>, <cmd> -h/--help, help, help commands, help --find <texte>, help <agent> (fiche info) - sections Usage/Flags/ Command Type/Parameters/Input-output/Examples avec couleurs - Commande version : tableau Nushell (commit, build, rustc, theme...) + --json ; build.rs pour commit/branche git ; -V reste la version courte - Themes : --theme <nom> (default, ocean, sunset, forest, dracula, mono) + settings.theme dans la config ; activation VT Windows pour les couleurs - Cadres blancs Nushell partages : list, search, status, doctor, ls/ps/ where/get et menu d autocompletion Tab encadre (colonnes name/description)
This commit is contained in:
+229
-13
@@ -143,6 +143,148 @@ impl Logger {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared boxed outputs (the Nushell-style "white frame")
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Truncate a string to max visible chars with an ellipsis.
|
||||
pub fn truncate(s: &str, max: usize) -> String {
|
||||
if s.chars().count() <= max {
|
||||
s.to_string()
|
||||
} else {
|
||||
let mut cut: String = s.chars().take(max.saturating_sub(1)).collect();
|
||||
cut.push('…');
|
||||
cut
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a boxed Nushell-style table (white frame, like ls) from plain rows
|
||||
/// and render it, fitted to the terminal width and the active theme.
|
||||
pub fn boxed_table(
|
||||
headers: Vec<&str>,
|
||||
rows: Vec<Vec<String>>,
|
||||
theme: &crate::theme::Theme,
|
||||
color: bool,
|
||||
) -> String {
|
||||
use crate::tables::{Cell, DataTable};
|
||||
let table = DataTable {
|
||||
columns: headers.into_iter().map(|h| h.to_string()).collect(),
|
||||
rows: rows
|
||||
.into_iter()
|
||||
.map(|r| r.into_iter().map(Cell::str).collect())
|
||||
.collect(),
|
||||
};
|
||||
table.render_themed(theme, color, terminal_width().unwrap_or(120))
|
||||
}
|
||||
|
||||
/// Render a two-column key/value box (no header row), like the version
|
||||
/// table. Keys use the theme key style, values the value style.
|
||||
pub fn kv_table(
|
||||
rows: &[(String, String)],
|
||||
theme: &crate::theme::Theme,
|
||||
color: bool,
|
||||
) -> String {
|
||||
let key_w = rows
|
||||
.iter()
|
||||
.map(|(k, _)| visible_len(k))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let val_w = rows
|
||||
.iter()
|
||||
.map(|(_, v)| visible_len(v))
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let line = format!(
|
||||
"{left}{}{mid}{}{right}",
|
||||
"─".repeat(key_w + 2),
|
||||
"─".repeat(val_w + 2)
|
||||
);
|
||||
if color {
|
||||
theme.dimmed(&line)
|
||||
} else {
|
||||
line
|
||||
}
|
||||
};
|
||||
let paint = |s: &str, color: bool, style: fn(&crate::theme::Theme, &str) -> String| {
|
||||
if color && !s.contains('\u{1b}') {
|
||||
style(theme, s)
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
let mut out = String::new();
|
||||
out.push_str(&border('╭', '┬', '╮'));
|
||||
out.push('\n');
|
||||
for (k, v) in rows {
|
||||
let key = paint(k, color, crate::theme::Theme::key);
|
||||
let val = paint(v, color, crate::theme::Theme::val);
|
||||
out.push_str(&format!(
|
||||
"│ {} │ {} │\n",
|
||||
pad(key.as_str(), key_w),
|
||||
pad(val.as_str(), val_w)
|
||||
));
|
||||
}
|
||||
out.push_str(&border('╰', '┴', '╯'));
|
||||
out.push('\n');
|
||||
out
|
||||
}
|
||||
|
||||
/// Build the lines of the boxed Tab-completion menu from (candidate,
|
||||
/// description) rows. Single column when no descriptions exist.
|
||||
pub fn completion_box(rows: &[(String, String)]) -> Vec<String> {
|
||||
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
|
||||
let name_w = rows
|
||||
.iter()
|
||||
.map(|(n, _)| n.chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("name".chars().count());
|
||||
let desc_w = if has_desc {
|
||||
rows.iter()
|
||||
.map(|(_, d)| truncate(d, 40).chars().count())
|
||||
.max()
|
||||
.unwrap_or(0)
|
||||
.max("description".chars().count())
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let border = |left: char, mid: char, right: char| -> String {
|
||||
let mut line = String::new();
|
||||
line.push(left);
|
||||
line.push_str(&"─".repeat(name_w + 2));
|
||||
if has_desc {
|
||||
line.push(mid);
|
||||
line.push_str(&"─".repeat(desc_w + 2));
|
||||
}
|
||||
line.push(right);
|
||||
line
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
out.push(border('╭', '┬', '╮'));
|
||||
if has_desc {
|
||||
out.push(format!(
|
||||
"│ {} │ {} │",
|
||||
pad("name", name_w),
|
||||
pad("description", desc_w)
|
||||
));
|
||||
out.push(border('├', '┼', '┤'));
|
||||
}
|
||||
for (name, desc) in rows {
|
||||
if has_desc {
|
||||
out.push(format!(
|
||||
"│ {} │ {} │",
|
||||
pad(name, name_w),
|
||||
pad(&truncate(desc, 40), desc_w)
|
||||
));
|
||||
} else {
|
||||
out.push(format!("│ {} │", pad(name, name_w)));
|
||||
}
|
||||
}
|
||||
out.push(border('╰', '┴', '╯'));
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tables
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -213,22 +355,21 @@ impl Table {
|
||||
}
|
||||
}
|
||||
|
||||
/// Color a status cell for display in tables.
|
||||
pub fn status_style(status: &str, color: bool) -> String {
|
||||
let s = status.to_string();
|
||||
/// Color a status cell for display in tables (theme-aware).
|
||||
pub fn status_style(status: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
||||
if !color {
|
||||
return s;
|
||||
return status.to_string();
|
||||
}
|
||||
match status {
|
||||
"running" => s.green().bold().to_string(),
|
||||
"installed" => s.cyan().to_string(),
|
||||
"external" => s.yellow().to_string(),
|
||||
"not-installable" => s.red().to_string(),
|
||||
"not-installed" => s.dimmed().to_string(),
|
||||
"ok" => s.green().to_string(),
|
||||
"warn" => s.yellow().to_string(),
|
||||
"error" => s.red().bold().to_string(),
|
||||
_ => s,
|
||||
"running" => theme.acc(status),
|
||||
"installed" => theme.inf(status),
|
||||
"external" => theme.warn(status),
|
||||
"not-installable" => theme.err(status),
|
||||
"not-installed" => theme.dimmed(status),
|
||||
"ok" => theme.ok(status),
|
||||
"warn" => theme.warn(status),
|
||||
"error" => theme.err(status),
|
||||
_ => status.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,3 +448,78 @@ fn pad(s: &str, width: usize) -> String {
|
||||
format!("{s}{}", " ".repeat(width - n))
|
||||
}
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn completion_box_with_descriptions_is_aligned() {
|
||||
let rows = vec![
|
||||
("list".to_string(), "list installed agents".to_string()),
|
||||
("status".to_string(), "show agent state".to_string()),
|
||||
];
|
||||
let lines = completion_box(&rows);
|
||||
assert_eq!(lines.len(), 6); // top, header, sep, 2 rows, bottom
|
||||
assert!(lines[0].starts_with('╭') && lines[0].contains('┬'));
|
||||
assert!(lines[0].ends_with('╮'));
|
||||
assert!(lines[1].contains("name") && lines[1].contains("description"));
|
||||
assert!(lines[2].starts_with('├'));
|
||||
assert!(lines[5].starts_with('╰'));
|
||||
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
|
||||
assert!(
|
||||
widths.iter().all(|w| *w == widths[0]),
|
||||
"unaligned: {lines:?}"
|
||||
);
|
||||
assert!(lines[3].contains("list") && lines[3].contains("list installed agents"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_box_without_descriptions_is_single_column() {
|
||||
let rows = vec![
|
||||
("src".to_string(), "".to_string()),
|
||||
("am.exe".to_string(), "".to_string()),
|
||||
];
|
||||
let lines = completion_box(&rows);
|
||||
assert_eq!(lines.len(), 4); // top, 2 rows, bottom
|
||||
assert!(!lines.iter().any(|l| l.contains("description")));
|
||||
let widths: Vec<usize> = lines.iter().map(|l| l.chars().count()).collect();
|
||||
assert!(widths.iter().all(|w| *w == widths[0]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kv_table_is_aligned_with_and_without_colors() {
|
||||
let rows = vec![
|
||||
("version".to_string(), "0.2.13".to_string()),
|
||||
("build_os".to_string(), "windows-x86_64".to_string()),
|
||||
];
|
||||
for color in [false, true] {
|
||||
let text = kv_table(&rows, crate::theme::default_theme(), color);
|
||||
// Compare visible widths (ANSI codes must not shift columns).
|
||||
let widths: Vec<usize> = text
|
||||
.lines()
|
||||
.map(|l| strip_ansi(l).chars().count())
|
||||
.collect();
|
||||
assert!(
|
||||
widths.iter().all(|w| *w == widths[0]),
|
||||
"unaligned ({color}):\n{text}"
|
||||
);
|
||||
}
|
||||
// Color output really contains ANSI sequences.
|
||||
let colored = kv_table(&rows, crate::theme::default_theme(), true);
|
||||
assert!(colored.contains("\x1b["));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxed_table_has_a_frame() {
|
||||
let text = boxed_table(
|
||||
vec!["NAME", "STATUS"],
|
||||
vec![vec!["claude-code".to_string(), "external".to_string()]],
|
||||
crate::theme::default_theme(),
|
||||
false,
|
||||
);
|
||||
assert!(text.contains("╭") && text.contains("╰"));
|
||||
assert!(text.contains("claude-code"));
|
||||
assert!(text.contains("NAME"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user