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:
Generated
+1
-1
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.2.13"
|
version = "0.2.14"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "agent-manager"
|
name = "agent-manager"
|
||||||
version = "0.2.13"
|
version = "0.2.14"
|
||||||
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"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! Record build metadata (git commit and branch) at compile time so the
|
||||||
|
//! 'am version' command can display it, like Nushell's version table.
|
||||||
|
//!
|
||||||
|
//! When git is unavailable (e.g. building from a source tarball) the values
|
||||||
|
//! are simply empty.
|
||||||
|
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
fn git(args: &[&str]) -> String {
|
||||||
|
Command::new("git")
|
||||||
|
.args(args)
|
||||||
|
.output()
|
||||||
|
.ok()
|
||||||
|
.filter(|o| o.status.success())
|
||||||
|
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
println!("cargo:rerun-if-changed=.git/HEAD");
|
||||||
|
println!("cargo:rerun-if-changed=.git/refs/heads");
|
||||||
|
let commit = git(&["rev-parse", "HEAD"]);
|
||||||
|
let branch = git(&["rev-parse", "--abbrev-ref", "HEAD"]);
|
||||||
|
// A detached HEAD reports the literal branch name "HEAD".
|
||||||
|
let branch = if branch == "HEAD" {
|
||||||
|
String::new()
|
||||||
|
} else {
|
||||||
|
branch
|
||||||
|
};
|
||||||
|
println!("cargo:rustc-env=AM_GIT_COMMIT={commit}");
|
||||||
|
println!("cargo:rustc-env=AM_GIT_BRANCH={branch}");
|
||||||
|
}
|
||||||
+27
-1
@@ -6,7 +6,8 @@ use crate::cli::Cli;
|
|||||||
use crate::config::{self, Config, Settings};
|
use crate::config::{self, Config, Settings};
|
||||||
use crate::output::Logger;
|
use crate::output::Logger;
|
||||||
use crate::state::StateStore;
|
use crate::state::StateStore;
|
||||||
use anyhow::{Context, Result};
|
use crate::theme::{self, Theme};
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
use std::io::{IsTerminal, Write};
|
use std::io::{IsTerminal, Write};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
@@ -19,6 +20,8 @@ pub struct App {
|
|||||||
pub catalog: Catalog,
|
pub catalog: Catalog,
|
||||||
pub state: StateStore,
|
pub state: StateStore,
|
||||||
pub log: Logger,
|
pub log: Logger,
|
||||||
|
/// Active output color theme.
|
||||||
|
pub theme: &'static Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -78,6 +81,7 @@ impl App {
|
|||||||
loaded.config.aliases.clone(),
|
loaded.config.aliases.clone(),
|
||||||
loaded.config.groups.clone(),
|
loaded.config.groups.clone(),
|
||||||
);
|
);
|
||||||
|
let theme = resolve_theme(cli.theme.as_deref(), loaded.config.settings.theme.as_deref())?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
cli,
|
cli,
|
||||||
config: loaded.config,
|
config: loaded.config,
|
||||||
@@ -86,6 +90,7 @@ impl App {
|
|||||||
catalog,
|
catalog,
|
||||||
state,
|
state,
|
||||||
log,
|
log,
|
||||||
|
theme,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,6 +110,10 @@ impl App {
|
|||||||
!self.cli.no_color && std::io::stdout().is_terminal()
|
!self.cli.no_color && std::io::stdout().is_terminal()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn theme(&self) -> &'static Theme {
|
||||||
|
self.theme
|
||||||
|
}
|
||||||
|
|
||||||
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
|
/// Ask the user for confirmation on stderr. Accepts y/yes/o/oui.
|
||||||
pub fn confirm(&self, question: &str) -> Result<bool> {
|
pub fn confirm(&self, question: &str) -> Result<bool> {
|
||||||
if self.cli.yes {
|
if self.cli.yes {
|
||||||
@@ -119,6 +128,23 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve the output color theme: the --theme flag wins, then the
|
||||||
|
/// configuration entry, then the default theme. An unknown --theme value is
|
||||||
|
/// an error; an unknown configured value silently falls back to the default.
|
||||||
|
fn resolve_theme(flag: Option<&str>, configured: Option<&str>) -> Result<&'static Theme> {
|
||||||
|
if let Some(name) = flag {
|
||||||
|
return theme::find(name).ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"unknown theme '{name}' — available: {}",
|
||||||
|
theme::names().join(", ")
|
||||||
|
)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(configured
|
||||||
|
.and_then(theme::find)
|
||||||
|
.unwrap_or_else(theme::default_theme))
|
||||||
|
}
|
||||||
|
|
||||||
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
|
/// Root directory for agent data (overridable with AGENT_MANAGER_DATA,
|
||||||
/// mostly used by tests).
|
/// mostly used by tests).
|
||||||
pub fn data_dir() -> PathBuf {
|
pub fn data_dir() -> PathBuf {
|
||||||
|
|||||||
+17
@@ -10,6 +10,9 @@ use std::path::PathBuf;
|
|||||||
name = "am",
|
name = "am",
|
||||||
bin_name = "am",
|
bin_name = "am",
|
||||||
version,
|
version,
|
||||||
|
// The help subcommand is provided by the custom Nushell-style help
|
||||||
|
// (cli::Command::Help) instead of clap's generated one.
|
||||||
|
disable_help_subcommand = true,
|
||||||
about = "agent-manager (am) — manage local AI coding agents",
|
about = "agent-manager (am) — manage local AI coding agents",
|
||||||
long_about = "agent-manager (am) — a CLI to manage local AI coding agents.
|
long_about = "agent-manager (am) — a CLI to manage local AI coding agents.
|
||||||
|
|
||||||
@@ -47,6 +50,10 @@ pub struct Cli {
|
|||||||
#[arg(long, global = true, action = ArgAction::SetTrue)]
|
#[arg(long, global = true, action = ArgAction::SetTrue)]
|
||||||
pub no_color: bool,
|
pub no_color: bool,
|
||||||
|
|
||||||
|
/// Color theme for the output: default, ocean, sunset, forest, dracula, mono
|
||||||
|
#[arg(long, global = true, value_name = "THEME")]
|
||||||
|
pub theme: Option<String>,
|
||||||
|
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
pub command: Option<Command>,
|
pub command: Option<Command>,
|
||||||
}
|
}
|
||||||
@@ -131,6 +138,16 @@ pub enum Command {
|
|||||||
/// Agent name or alias
|
/// Agent name or alias
|
||||||
agent: String,
|
agent: String,
|
||||||
},
|
},
|
||||||
|
/// Display help: the overview, a command, the command list, or a search
|
||||||
|
Help {
|
||||||
|
/// Command name (also accepts an agent name or alias); "commands" lists every command
|
||||||
|
command: Option<String>,
|
||||||
|
/// Search through all help commands table
|
||||||
|
#[arg(long, value_name = "TEXT")]
|
||||||
|
find: Option<String>,
|
||||||
|
},
|
||||||
|
/// Display version and build information
|
||||||
|
Version,
|
||||||
/// Manage the configuration file
|
/// Manage the configuration file
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
Config(ConfigCmd),
|
Config(ConfigCmd),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::doctor::{self, Status};
|
use crate::doctor::{self, Status};
|
||||||
use crate::output::{print_json, status_style, Table};
|
use crate::output::{boxed_table, print_json, status_style};
|
||||||
use crate::runner::SystemRunner;
|
use crate::runner::SystemRunner;
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
|
|
||||||
@@ -27,16 +27,25 @@ pub fn run(app: &App, fix: bool) -> Result<i32> {
|
|||||||
return Ok(if report.ok() { 0 } else { 1 });
|
return Ok(if report.ok() { 0 } else { 1 });
|
||||||
}
|
}
|
||||||
let color = app.color();
|
let color = app.color();
|
||||||
let mut table = Table::new(vec!["CHECK", "STATUS", "DETAIL"]);
|
let theme = app.theme();
|
||||||
|
let mut table_rows = Vec::new();
|
||||||
for c in &report.checks {
|
for c in &report.checks {
|
||||||
let status = match c.status {
|
let status = match c.status {
|
||||||
Status::Ok => status_style("ok", color),
|
Status::Ok => status_style("ok", theme, color),
|
||||||
Status::Warn => status_style("warn", color),
|
Status::Warn => status_style("warn", theme, color),
|
||||||
Status::Error => status_style("error", color),
|
Status::Error => status_style("error", theme, color),
|
||||||
};
|
};
|
||||||
table.row(vec![c.name.clone(), status, c.detail.clone()]);
|
table_rows.push(vec![c.name.clone(), status, c.detail.clone()]);
|
||||||
}
|
}
|
||||||
print!("{}", table.render());
|
print!(
|
||||||
|
"{}",
|
||||||
|
boxed_table(
|
||||||
|
vec!["CHECK", "STATUS", "DETAIL"],
|
||||||
|
table_rows,
|
||||||
|
theme,
|
||||||
|
color,
|
||||||
|
)
|
||||||
|
);
|
||||||
if report.problems() > 0 {
|
if report.problems() > 0 {
|
||||||
app.log
|
app.log
|
||||||
.warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems()));
|
.warn(&format!("{} problem(s) found — 'am doctor --fix' repairs what it can", report.problems()));
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
//! help: Nushell-style help — the overview, one command, the command list,
|
||||||
|
//! or a keyword search through it.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
pub fn run(app: &App, command: Option<&str>, find: Option<&str>) -> Result<i32> {
|
||||||
|
if let Some(text) = find {
|
||||||
|
crate::help::print_find(app, text);
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
crate::help::print_topic(app, command)
|
||||||
|
}
|
||||||
@@ -154,7 +154,7 @@ pub fn run(app: &App, name: &str) -> Result<i32> {
|
|||||||
} else {
|
} else {
|
||||||
("not-installed", String::new())
|
("not-installed", String::new())
|
||||||
};
|
};
|
||||||
let word = crate::output::status_style(status_word, color);
|
let word = crate::output::status_style(status_word, app.theme(), color);
|
||||||
println!(" {} {}", dim("● status"), format!("{word}{detail}"));
|
println!(" {} {}", dim("● status"), format!("{word}{detail}"));
|
||||||
|
|
||||||
// Version: recorded for managed installs, detected for external ones.
|
// Version: recorded for managed installs, detected for external ones.
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//! the whole catalog with --all.
|
//! the whole catalog with --all.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::output::{print_json, status_style, Table};
|
use crate::output::{boxed_table, print_json, status_style};
|
||||||
use crate::probe::ExternalInfo;
|
use crate::probe::ExternalInfo;
|
||||||
use crate::state::InstalledEntry;
|
use crate::state::InstalledEntry;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -50,18 +50,27 @@ pub fn run(app: &App, all: bool, category: Option<&str>) -> Result<i32> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let color = app.color();
|
let color = app.color();
|
||||||
let mut table = Table::new(vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"]);
|
let theme = app.theme();
|
||||||
|
let mut table_rows = Vec::new();
|
||||||
for r in &rows {
|
for r in &rows {
|
||||||
table.row(vec![
|
table_rows.push(vec![
|
||||||
r.display_name.clone(),
|
r.display_name.clone(),
|
||||||
r.name.clone(),
|
r.name.clone(),
|
||||||
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
status_style(&r.status, color),
|
status_style(&r.status, theme, color),
|
||||||
r.source.clone(),
|
r.source.clone(),
|
||||||
r.path.clone().unwrap_or_else(|| "—".to_string()),
|
r.path.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
print!("{}", table.render());
|
print!(
|
||||||
|
"{}",
|
||||||
|
boxed_table(
|
||||||
|
vec!["NAME", "AGENT", "VERSION", "STATUS", "SOURCE", "PATH"],
|
||||||
|
table_rows,
|
||||||
|
theme,
|
||||||
|
color,
|
||||||
|
)
|
||||||
|
);
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
app.log.info(if all {
|
app.log.info(if all {
|
||||||
"no agents found"
|
"no agents found"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod completion_cmd;
|
|||||||
pub mod config_cmd;
|
pub mod config_cmd;
|
||||||
pub mod doctor_cmd;
|
pub mod doctor_cmd;
|
||||||
pub mod export_import;
|
pub mod export_import;
|
||||||
|
pub mod help_cmd;
|
||||||
pub mod info_cmd;
|
pub mod info_cmd;
|
||||||
pub mod install_cmd;
|
pub mod install_cmd;
|
||||||
pub mod list_cmd;
|
pub mod list_cmd;
|
||||||
@@ -14,6 +15,7 @@ pub mod self_update;
|
|||||||
pub mod status_cmd;
|
pub mod status_cmd;
|
||||||
pub mod uninstall_cmd;
|
pub mod uninstall_cmd;
|
||||||
pub mod update_cmd;
|
pub mod update_cmd;
|
||||||
|
pub mod version_cmd;
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::cli::Command;
|
use crate::cli::Command;
|
||||||
@@ -55,6 +57,8 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
|||||||
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
Command::Update { agent, all } => update_cmd::run(app, agent.as_deref(), *all),
|
||||||
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
|
Command::Search { keyword, category } => search_cmd::run(app, keyword, category.as_deref()),
|
||||||
Command::Info { agent } => info_cmd::run(app, agent),
|
Command::Info { agent } => info_cmd::run(app, agent),
|
||||||
|
Command::Help { command, find } => help_cmd::run(app, command.as_deref(), find.as_deref()),
|
||||||
|
Command::Version => version_cmd::run(app),
|
||||||
Command::Config(sub) => config_cmd::run(app, sub),
|
Command::Config(sub) => config_cmd::run(app, sub),
|
||||||
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
Command::Doctor { fix } => doctor_cmd::run(app, *fix),
|
||||||
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
Command::Run { agent, args } => run_cmd::run(app, agent, args),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
//! search: keyword search over the catalog.
|
//! search: keyword search over the catalog.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::output::{print_json, Table};
|
use crate::output::{boxed_table, print_json};
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -33,17 +33,25 @@ pub fn run(app: &App, keyword: &str, category: Option<&str>) -> Result<i32> {
|
|||||||
print_json(&rows);
|
print_json(&rows);
|
||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let mut table = Table::new(vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"]);
|
let mut table_rows = Vec::new();
|
||||||
for a in hits {
|
for a in hits {
|
||||||
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
|
let desc = truncate(a.description.as_deref().unwrap_or(""), 56);
|
||||||
table.row(vec![
|
table_rows.push(vec![
|
||||||
a.title().to_string(),
|
a.title().to_string(),
|
||||||
a.category.clone().unwrap_or_else(|| "—".to_string()),
|
a.category.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
if a.installable { "yes" } else { "no" }.to_string(),
|
if a.installable { "yes" } else { "no" }.to_string(),
|
||||||
desc,
|
desc,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
print!("{}", table.render());
|
print!(
|
||||||
|
"{}",
|
||||||
|
boxed_table(
|
||||||
|
vec!["NAME", "CATEGORY", "INSTALLABLE", "DESCRIPTION"],
|
||||||
|
table_rows,
|
||||||
|
app.theme(),
|
||||||
|
app.color(),
|
||||||
|
)
|
||||||
|
);
|
||||||
if app.catalog.search(keyword, category).is_empty() {
|
if app.catalog.search(keyword, category).is_empty() {
|
||||||
app.log
|
app.log
|
||||||
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
|
.info(&format!("no agent matches '{keyword}' (categories: {})", app.catalog.categories().join(", ")));
|
||||||
|
|||||||
+41
-18
@@ -1,7 +1,7 @@
|
|||||||
//! status: detailed state of one agent, or of every installed agent.
|
//! status: detailed state of one agent, or of every installed agent.
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::output::{print_json, status_style, Table};
|
use crate::output::{boxed_table, kv_table, print_json, status_style};
|
||||||
use crate::probe::ExternalInfo;
|
use crate::probe::ExternalInfo;
|
||||||
use crate::state::InstalledEntry;
|
use crate::state::InstalledEntry;
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
@@ -39,28 +39,42 @@ fn status_one(app: &App, name: &str, probe: &ExternalInfo) -> Result<i32> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let color = app.color();
|
let color = app.color();
|
||||||
|
let theme = app.theme();
|
||||||
println!("{} ({})", agent.title(), agent.name);
|
println!("{} ({})", agent.title(), agent.name);
|
||||||
let status_line = status_style(&detail.status, color);
|
let status_line = status_style(&detail.status, theme, color);
|
||||||
println!(" status: {status_line}");
|
let mut rows: Vec<(String, String)> = vec![
|
||||||
println!(
|
("agent".to_string(), agent.name.clone()),
|
||||||
" version: {}",
|
("status".to_string(), status_line),
|
||||||
detail.version.as_deref().unwrap_or("unknown")
|
(
|
||||||
);
|
"version".to_string(),
|
||||||
println!(" method: {}", detail.method.as_deref().unwrap_or("—"));
|
detail.version.clone().unwrap_or_else(|| "unknown".to_string()),
|
||||||
println!(" path: {}", detail.path.as_deref().unwrap_or("—"));
|
),
|
||||||
println!(" run: {}", detail.run.as_deref().unwrap_or("—"));
|
(
|
||||||
|
"method".to_string(),
|
||||||
|
detail.method.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"path".to_string(),
|
||||||
|
detail.path.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"run".to_string(),
|
||||||
|
detail.run.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
|
),
|
||||||
|
];
|
||||||
if let Some(pid) = detail.pid {
|
if let Some(pid) = detail.pid {
|
||||||
println!(" pid: {pid}");
|
rows.push(("pid".to_string(), pid.to_string()));
|
||||||
}
|
}
|
||||||
if let Some(t) = &detail.started_at {
|
if let Some(t) = &detail.started_at {
|
||||||
println!(" started: {t}");
|
rows.push(("started".to_string(), t.clone()));
|
||||||
}
|
}
|
||||||
if let Some(t) = &detail.installed_at {
|
if let Some(t) = &detail.installed_at {
|
||||||
println!(" installed: {t}");
|
rows.push(("installed".to_string(), t.clone()));
|
||||||
}
|
}
|
||||||
if let Some(l) = &detail.log {
|
if let Some(l) = &detail.log {
|
||||||
println!(" log: {l}");
|
rows.push(("log".to_string(), l.clone()));
|
||||||
}
|
}
|
||||||
|
print!("{}", kv_table(&rows, theme, color));
|
||||||
Ok(0)
|
Ok(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,17 +104,26 @@ fn status_all(app: &App, probe: &ExternalInfo) -> Result<i32> {
|
|||||||
return Ok(0);
|
return Ok(0);
|
||||||
}
|
}
|
||||||
let color = app.color();
|
let color = app.color();
|
||||||
let mut table = Table::new(vec!["NAME", "VERSION", "STATUS", "PID", "PATH"]);
|
let theme = app.theme();
|
||||||
|
let mut table_rows = Vec::new();
|
||||||
for r in &rows {
|
for r in &rows {
|
||||||
table.row(vec![
|
table_rows.push(vec![
|
||||||
r.display_name.clone(),
|
r.display_name.clone(),
|
||||||
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
r.version.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
status_style(&r.status, color),
|
status_style(&r.status, theme, color),
|
||||||
r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()),
|
r.pid.map(|p| p.to_string()).unwrap_or_else(|| "—".to_string()),
|
||||||
r.path.clone().unwrap_or_else(|| "—".to_string()),
|
r.path.clone().unwrap_or_else(|| "—".to_string()),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
print!("{}", table.render());
|
print!(
|
||||||
|
"{}",
|
||||||
|
boxed_table(
|
||||||
|
vec!["NAME", "VERSION", "STATUS", "PID", "PATH"],
|
||||||
|
table_rows,
|
||||||
|
theme,
|
||||||
|
color,
|
||||||
|
)
|
||||||
|
);
|
||||||
if rows.is_empty() {
|
if rows.is_empty() {
|
||||||
app.log.info("no installed or running agents");
|
app.log.info("no installed or running agents");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
//! version: version and build information, Nushell-style.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
use crate::help::{version_info, version_table};
|
||||||
|
use crate::output::print_json;
|
||||||
|
|
||||||
|
pub fn run(app: &App) -> Result<i32> {
|
||||||
|
let info = version_info();
|
||||||
|
if app.json() {
|
||||||
|
print_json(&info);
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
print!("{}", version_table(&info, app.theme(), app.color()));
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
@@ -66,6 +66,10 @@ pub struct Settings {
|
|||||||
/// GitHub repository (owner/repo) used by the self-update command.
|
/// GitHub repository (owner/repo) used by the self-update command.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub self_update_repo: Option<String>,
|
pub self_update_repo: Option<String>,
|
||||||
|
/// Color theme for the output: default, ocean, sunset, forest, dracula,
|
||||||
|
/// mono (the --theme flag overrides it).
|
||||||
|
#[serde(default)]
|
||||||
|
pub theme: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Settings {
|
impl Settings {
|
||||||
|
|||||||
+1364
File diff suppressed because it is too large
Load Diff
+13
@@ -19,6 +19,7 @@ pub mod config;
|
|||||||
pub mod deps;
|
pub mod deps;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
pub mod download;
|
pub mod download;
|
||||||
|
pub mod help;
|
||||||
pub mod installers;
|
pub mod installers;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
@@ -30,6 +31,7 @@ pub mod runner;
|
|||||||
pub mod shell;
|
pub mod shell;
|
||||||
pub mod state;
|
pub mod state;
|
||||||
pub mod tables;
|
pub mod tables;
|
||||||
|
pub mod theme;
|
||||||
pub mod toolchain;
|
pub mod toolchain;
|
||||||
pub mod version;
|
pub mod version;
|
||||||
|
|
||||||
@@ -37,6 +39,17 @@ use clap::Parser;
|
|||||||
|
|
||||||
/// CLI entry point used by the am binary. Returns the process exit code.
|
/// CLI entry point used by the am binary. Returns the process exit code.
|
||||||
pub fn main_entry() -> i32 {
|
pub fn main_entry() -> i32 {
|
||||||
|
// Enable ANSI escape sequences on Windows consoles (best effort): the
|
||||||
|
// colored crate and the theme palettes both emit raw VT codes.
|
||||||
|
#[cfg(windows)]
|
||||||
|
let _ = colored::control::set_virtual_terminal(true);
|
||||||
|
|
||||||
|
// Nushell-style help: 'am -h', 'am install -h', 'am ls --help', ... all
|
||||||
|
// show the custom help before clap gets a chance to parse the line.
|
||||||
|
let args: Vec<String> = std::env::args().collect();
|
||||||
|
if let Some(code) = help::intercept(&args) {
|
||||||
|
return code;
|
||||||
|
}
|
||||||
let cli = cli::Cli::parse();
|
let cli = cli::Cli::parse();
|
||||||
match app::App::from_cli(cli) {
|
match app::App::from_cli(cli) {
|
||||||
Ok(app) => match commands::execute(&app) {
|
Ok(app) => match commands::execute(&app) {
|
||||||
|
|||||||
+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
|
// Tables
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -213,22 +355,21 @@ impl Table {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Color a status cell for display in tables.
|
/// Color a status cell for display in tables (theme-aware).
|
||||||
pub fn status_style(status: &str, color: bool) -> String {
|
pub fn status_style(status: &str, theme: &crate::theme::Theme, color: bool) -> String {
|
||||||
let s = status.to_string();
|
|
||||||
if !color {
|
if !color {
|
||||||
return s;
|
return status.to_string();
|
||||||
}
|
}
|
||||||
match status {
|
match status {
|
||||||
"running" => s.green().bold().to_string(),
|
"running" => theme.acc(status),
|
||||||
"installed" => s.cyan().to_string(),
|
"installed" => theme.inf(status),
|
||||||
"external" => s.yellow().to_string(),
|
"external" => theme.warn(status),
|
||||||
"not-installable" => s.red().to_string(),
|
"not-installable" => theme.err(status),
|
||||||
"not-installed" => s.dimmed().to_string(),
|
"not-installed" => theme.dimmed(status),
|
||||||
"ok" => s.green().to_string(),
|
"ok" => theme.ok(status),
|
||||||
"warn" => s.yellow().to_string(),
|
"warn" => theme.warn(status),
|
||||||
"error" => s.red().bold().to_string(),
|
"error" => theme.err(status),
|
||||||
_ => s,
|
_ => status.to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -307,3 +448,78 @@ fn pad(s: &str, width: usize) -> String {
|
|||||||
format!("{s}{}", " ".repeat(width - n))
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
+202
-63
@@ -13,14 +13,13 @@
|
|||||||
//! available.
|
//! available.
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::cli::{Cli, Command, StartArgs};
|
use crate::cli::{Command, StartArgs};
|
||||||
use crate::commands;
|
use crate::commands;
|
||||||
use crate::nav;
|
use crate::nav;
|
||||||
use crate::ps;
|
use crate::ps;
|
||||||
use crate::tables::DataTable;
|
use crate::tables::DataTable;
|
||||||
use crate::shell::ShellSession;
|
use crate::shell::ShellSession;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use clap::CommandFactory;
|
|
||||||
use colored::Colorize;
|
use colored::Colorize;
|
||||||
use rustyline::completion::{Completer, Pair};
|
use rustyline::completion::{Completer, Pair};
|
||||||
use rustyline::error::ReadlineError;
|
use rustyline::error::ReadlineError;
|
||||||
@@ -50,6 +49,8 @@ pub struct AmCompleter {
|
|||||||
/// Short descriptions shown next to candidates in the Tab menu
|
/// Short descriptions shown next to candidates in the Tab menu
|
||||||
/// (Nushell-style).
|
/// (Nushell-style).
|
||||||
descriptions: BTreeMap<String, String>,
|
descriptions: BTreeMap<String, String>,
|
||||||
|
/// Active color theme (used by the boxed menu highlight).
|
||||||
|
theme: &'static crate::theme::Theme,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-line descriptions for the Tab completion menu.
|
/// One-line descriptions for the Tab completion menu.
|
||||||
@@ -86,7 +87,10 @@ const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
|
|||||||
|
|
||||||
impl AmCompleter {
|
impl AmCompleter {
|
||||||
pub fn new(app: &App) -> Self {
|
pub fn new(app: &App) -> Self {
|
||||||
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups)
|
let mut completer =
|
||||||
|
Self::from_catalog(&app.catalog, &app.config.aliases, &app.config.groups);
|
||||||
|
completer.theme = app.theme();
|
||||||
|
completer
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn from_catalog(
|
pub fn from_catalog(
|
||||||
@@ -121,6 +125,7 @@ impl AmCompleter {
|
|||||||
descriptions.insert(a.name.clone(), truncate_desc(&desc));
|
descriptions.insert(a.name.clone(), truncate_desc(&desc));
|
||||||
}
|
}
|
||||||
Self {
|
Self {
|
||||||
|
theme: crate::theme::default_theme(),
|
||||||
commands: vec![
|
commands: vec![
|
||||||
"list", "status", "search", "info", "install", "uninstall", "update",
|
"list", "status", "search", "info", "install", "uninstall", "update",
|
||||||
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
"start", "stop", "restart", "run", "doctor", "config", "completion",
|
||||||
@@ -239,25 +244,82 @@ impl Completer for AmCompleter {
|
|||||||
_ctx: &Context<'_>,
|
_ctx: &Context<'_>,
|
||||||
) -> rustyline::Result<(usize, Vec<Pair>)> {
|
) -> rustyline::Result<(usize, Vec<Pair>)> {
|
||||||
let (start, cands) = self.candidates_for(line, pos);
|
let (start, cands) = self.candidates_for(line, pos);
|
||||||
let pairs = cands
|
let pairs = if cands.len() < 2 {
|
||||||
.into_iter()
|
// A single match is inserted immediately (no menu).
|
||||||
.map(|c| {
|
cands
|
||||||
// Nushell-style menu: show a description next to the
|
.into_iter()
|
||||||
// candidate when one is known.
|
.map(|c| Pair {
|
||||||
let display = match self.descriptions.get(&c) {
|
display: c.clone(),
|
||||||
Some(d) if !d.is_empty() => format!("{c}\t{d}"),
|
|
||||||
_ => c.clone(),
|
|
||||||
};
|
|
||||||
Pair {
|
|
||||||
display,
|
|
||||||
replacement: c,
|
replacement: c,
|
||||||
}
|
})
|
||||||
})
|
.collect()
|
||||||
.collect();
|
} else {
|
||||||
|
self.boxed_pairs(&cands)
|
||||||
|
};
|
||||||
Ok((start, pairs))
|
Ok((start, pairs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl AmCompleter {
|
||||||
|
/// Build the boxed Tab menu: one candidate per box line, so rustyline's
|
||||||
|
/// single-column layout renders the whole menu as one white-framed
|
||||||
|
/// table. Decorative lines (borders, header) replace the typed word
|
||||||
|
/// with the common prefix, so completion behaves exactly as before.
|
||||||
|
fn boxed_pairs(&self, cands: &[String]) -> Vec<Pair> {
|
||||||
|
let rows: Vec<(String, String)> = cands
|
||||||
|
.iter()
|
||||||
|
.map(|c| (c.clone(), self.descriptions.get(c).cloned().unwrap_or_default()))
|
||||||
|
.collect();
|
||||||
|
let has_desc = rows.iter().any(|(_, d)| !d.is_empty());
|
||||||
|
let lines = crate::output::completion_box(&rows);
|
||||||
|
let lcp = common_prefix(cands);
|
||||||
|
let n = cands.len();
|
||||||
|
let data_start = if has_desc { 3 } else { 1 };
|
||||||
|
// Pad every line to the terminal width: rustyline then lays the
|
||||||
|
// menu out in one column, one box line per row.
|
||||||
|
let width = crate::output::terminal_width()
|
||||||
|
.unwrap_or(120)
|
||||||
|
.max(lines.iter().map(|l| l.chars().count()).max().unwrap_or(0));
|
||||||
|
lines
|
||||||
|
.into_iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, line)| {
|
||||||
|
let replacement = if i >= data_start && i < data_start + n {
|
||||||
|
cands[i - data_start].clone()
|
||||||
|
} else {
|
||||||
|
lcp.clone()
|
||||||
|
};
|
||||||
|
let display = format!(
|
||||||
|
"{line}{}",
|
||||||
|
" ".repeat(width.saturating_sub(line.chars().count()))
|
||||||
|
);
|
||||||
|
Pair {
|
||||||
|
display,
|
||||||
|
replacement,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Longest common prefix of the candidates (chars, not bytes).
|
||||||
|
fn common_prefix(items: &[String]) -> String {
|
||||||
|
let mut iter = items.iter();
|
||||||
|
let Some(first) = iter.next() else {
|
||||||
|
return String::new();
|
||||||
|
};
|
||||||
|
let mut prefix = first.clone();
|
||||||
|
for s in iter {
|
||||||
|
while !s.starts_with(&prefix) && !prefix.is_empty() {
|
||||||
|
prefix.pop();
|
||||||
|
}
|
||||||
|
if prefix.is_empty() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
prefix
|
||||||
|
}
|
||||||
|
|
||||||
impl Hinter for AmCompleter {
|
impl Hinter for AmCompleter {
|
||||||
type Hint = String;
|
type Hint = String;
|
||||||
|
|
||||||
@@ -266,7 +328,39 @@ impl Hinter for AmCompleter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Highlighter for AmCompleter {}
|
impl Highlighter for AmCompleter {
|
||||||
|
/// Style the boxed completion menu: borders dim, the header row in the
|
||||||
|
/// theme header style, candidate names in the accent color.
|
||||||
|
fn highlight_candidate<'c>(
|
||||||
|
&self,
|
||||||
|
candidate: &'c str,
|
||||||
|
completion: rustyline::CompletionType,
|
||||||
|
) -> std::borrow::Cow<'c, str> {
|
||||||
|
use std::borrow::Cow;
|
||||||
|
let _ = completion;
|
||||||
|
match candidate.chars().next() {
|
||||||
|
Some('╭') | Some('╰') | Some('├') => Cow::Owned(self.theme.dimmed(candidate)),
|
||||||
|
Some('│') => {
|
||||||
|
let cells: Vec<&str> = candidate.split('│').collect();
|
||||||
|
if cells.len() >= 4 {
|
||||||
|
if cells[1].trim() == "name" && cells[2].trim() == "description" {
|
||||||
|
Cow::Owned(self.theme.hdr(candidate))
|
||||||
|
} else {
|
||||||
|
Cow::Owned(format!(
|
||||||
|
"│{}│{}│{}",
|
||||||
|
self.theme.acc(cells[1]),
|
||||||
|
cells[2],
|
||||||
|
cells[3]
|
||||||
|
))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Cow::Borrowed(candidate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => Cow::Borrowed(candidate),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
impl Validator for AmCompleter {}
|
impl Validator for AmCompleter {}
|
||||||
impl Helper for AmCompleter {}
|
impl Helper for AmCompleter {}
|
||||||
|
|
||||||
@@ -682,9 +776,7 @@ pub fn run(app: &App) -> Result<i32> {
|
|||||||
let forced = std::env::var_os("AM_FORCE_REPL").is_some();
|
let forced = std::env::var_os("AM_FORCE_REPL").is_some();
|
||||||
if !std::io::stdin().is_terminal() && !forced {
|
if !std::io::stdin().is_terminal() && !forced {
|
||||||
// Piped stdin: print the help instead of hanging.
|
// Piped stdin: print the help instead of hanging.
|
||||||
let mut cmd = Cli::command();
|
crate::help::print_general(app);
|
||||||
cmd.print_help()?;
|
|
||||||
println!();
|
|
||||||
return Ok(2);
|
return Ok(2);
|
||||||
}
|
}
|
||||||
let mut session = ShellSession::start(&app.config);
|
let mut session = ShellSession::start(&app.config);
|
||||||
@@ -786,11 +878,11 @@ fn handle_input(
|
|||||||
match line {
|
match line {
|
||||||
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
|
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
|
||||||
"help" | "?" | "/help" => {
|
"help" | "?" | "/help" => {
|
||||||
print_help();
|
crate::help::print_general(app);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
"version" | "/version" => {
|
"version" | "/version" => {
|
||||||
println!("am {}", env!("CARGO_PKG_VERSION"));
|
crate::help::print_version(app);
|
||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -803,6 +895,14 @@ fn handle_input(
|
|||||||
};
|
};
|
||||||
match cmd {
|
match cmd {
|
||||||
"shell" => shell_command(session, arg),
|
"shell" => shell_command(session, arg),
|
||||||
|
"help" => match arg {
|
||||||
|
Some(topic) => {
|
||||||
|
if let Err(e) = crate::help::print_command(app, topic) {
|
||||||
|
app.log.error(format!("{e:#}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => crate::help::print_general(app),
|
||||||
|
},
|
||||||
_ => app
|
_ => app
|
||||||
.log
|
.log
|
||||||
.error(&format!("unknown command '/{cmd}' — type '/help'")),
|
.error(&format!("unknown command '/{cmd}' — type '/help'")),
|
||||||
@@ -839,6 +939,35 @@ fn handle_line(
|
|||||||
let cmd = tokens[0].as_str();
|
let cmd = tokens[0].as_str();
|
||||||
let rest = &tokens[1..];
|
let rest = &tokens[1..];
|
||||||
|
|
||||||
|
// Nushell-style help: 'ls -h', 'ls --help', 'start -h', ... show the
|
||||||
|
// colored command help. System commands keep their own -h handling.
|
||||||
|
if is_am_command(cmd) || is_shell_command(cmd) {
|
||||||
|
let help_flag_pos = rest.iter().position(|t| t == "-h" || t == "--help");
|
||||||
|
let dd_pos = rest.iter().position(|t| t == "--");
|
||||||
|
let help_requested = match help_flag_pos {
|
||||||
|
Some(p) => dd_pos.is_none_or(|d| p < d),
|
||||||
|
None => false,
|
||||||
|
};
|
||||||
|
// 'run <agent> --help' passes --help through to the agent; only
|
||||||
|
// 'run -h' right after the command name is a help request.
|
||||||
|
let passthrough = cmd == "run" && help_flag_pos != Some(0);
|
||||||
|
if help_requested && !passthrough {
|
||||||
|
if let Err(e) = crate::help::print_command(app, cmd) {
|
||||||
|
app.log.error(format!("{e:#}"));
|
||||||
|
}
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// help <topic> | help --find <text> | help commands
|
||||||
|
if cmd == "help" {
|
||||||
|
return run_help_command(app, rest);
|
||||||
|
}
|
||||||
|
if cmd == "version" {
|
||||||
|
crate::help::print_version(app);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
// Session directory change: kept in the am process, not a child shell.
|
// Session directory change: kept in the am process, not a child shell.
|
||||||
if matches!(cmd, "cd" | "chdir") {
|
if matches!(cmd, "cd" | "chdir") {
|
||||||
change_dir(rest)?;
|
change_dir(rest)?;
|
||||||
@@ -999,6 +1128,14 @@ fn handle_line(
|
|||||||
Ok(exit_after)
|
Ok(exit_after)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a word is one of the REPL's shell-level commands (ls, ps, ...).
|
||||||
|
fn is_shell_command(word: &str) -> bool {
|
||||||
|
matches!(
|
||||||
|
word,
|
||||||
|
"ls" | "dir" | "ps" | "where" | "get" | "cd" | "shell" | "help" | "version" | "exit"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a word is one of the am commands (everything else is executed by
|
/// Whether a word is one of the am commands (everything else is executed by
|
||||||
/// the system shell).
|
/// the system shell).
|
||||||
fn is_am_command(word: &str) -> bool {
|
fn is_am_command(word: &str) -> bool {
|
||||||
@@ -1027,7 +1164,14 @@ fn is_am_command(word: &str) -> bool {
|
|||||||
|
|
||||||
/// Resolve the path argument of ls/dir (defaults to the current directory).
|
/// Resolve the path argument of ls/dir (defaults to the current directory).
|
||||||
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
|
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
|
||||||
Ok(match rest.first() {
|
// A leading '--' ends flag parsing, so 'ls -- -h' can list a file
|
||||||
|
// literally named "-h" instead of showing the help.
|
||||||
|
let args: Vec<&String> = if rest.first().map(String::as_str) == Some("--") {
|
||||||
|
rest.iter().skip(1).collect()
|
||||||
|
} else {
|
||||||
|
rest.iter().collect()
|
||||||
|
};
|
||||||
|
Ok(match args.first() {
|
||||||
Some(p) => crate::config::expand_path(p),
|
Some(p) => crate::config::expand_path(p),
|
||||||
None => current_dir(),
|
None => current_dir(),
|
||||||
})
|
})
|
||||||
@@ -1037,7 +1181,10 @@ fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
|
|||||||
/// The table is rendered to fit the terminal width so rows never wrap.
|
/// 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) {
|
||||||
let width = crate::output::terminal_width().unwrap_or(120);
|
let width = crate::output::terminal_width().unwrap_or(120);
|
||||||
print!("{}", table.render(app.color(), width));
|
print!(
|
||||||
|
"{}",
|
||||||
|
table.render_themed(app.theme(), app.color(), width)
|
||||||
|
);
|
||||||
*last = Some(table);
|
*last = Some(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1057,6 +1204,18 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
|
|||||||
}
|
}
|
||||||
let cmd = tokens[0].as_str();
|
let cmd = tokens[0].as_str();
|
||||||
let rest = &tokens[1..];
|
let rest = &tokens[1..];
|
||||||
|
// Nushell-style: 'ls -h' inside a pipeline shows the command help.
|
||||||
|
if rest
|
||||||
|
.iter()
|
||||||
|
.take_while(|t| t.as_str() != "--")
|
||||||
|
.any(|t| t == "-h" || t == "--help")
|
||||||
|
&& matches!(cmd, "ls" | "dir" | "ps" | "where" | "get")
|
||||||
|
{
|
||||||
|
if let Err(e) = crate::help::print_command(app, cmd) {
|
||||||
|
app.log.error(format!("{e:#}"));
|
||||||
|
}
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
match cmd {
|
match cmd {
|
||||||
"ls" => {
|
"ls" => {
|
||||||
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
|
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
|
||||||
@@ -1157,44 +1316,24 @@ fn print_shell_status(session: &ShellSession) {
|
|||||||
println!("use 'shell <name>' to switch; set settings.default_shell in the config to make it permanent");
|
println!("use 'shell <name>' to switch; set settings.default_shell in the config to make it permanent");
|
||||||
}
|
}
|
||||||
|
|
||||||
fn print_help() {
|
/// 'help' in the REPL: the overview, a command/agent topic, the command
|
||||||
println!("commands:");
|
/// list, or a --find search — the same Nushell-style help as 'am help'.
|
||||||
println!(" list [--all] list installed agents (--all: whole catalog)");
|
fn run_help_command(app: &App, rest: &[String]) -> Result<bool> {
|
||||||
println!(" status [agent] show agent state");
|
let mut iter = rest.iter();
|
||||||
println!(" search <keyword> search the catalog");
|
match iter.next().map(|s| s.as_str()) {
|
||||||
println!(" info <agent> show agent details");
|
None => crate::help::print_general(app),
|
||||||
println!(" install <agent> [--method <m>] install an agent (--force to reinstall)");
|
Some("--find") => {
|
||||||
println!(" uninstall <agent> [--purge] remove an agent");
|
let text = iter.next().map(|s| s.as_str()).unwrap_or("");
|
||||||
println!(" update <agent> | update --all update agents");
|
crate::help::print_find(app, text);
|
||||||
println!(" start <agent> [--background] start an agent");
|
}
|
||||||
println!(" stop <agent> [--force] stop an agent");
|
Some("-h") | Some("--help") => {
|
||||||
println!(" restart <agent> [--background] restart an agent");
|
crate::help::print_command(app, "help")?;
|
||||||
println!(" run <agent> [args...] run an agent command directly");
|
}
|
||||||
println!(" doctor [--fix] check the environment");
|
Some(topic) => {
|
||||||
println!(" config show | path | validate | edit");
|
crate::help::print_command(app, topic)?;
|
||||||
println!(" completion <shell> generate a completion script");
|
}
|
||||||
println!(" self-update [--check] update agent-manager");
|
}
|
||||||
println!(" self-uninstall remove agent-manager and everything it created");
|
Ok(false)
|
||||||
println!(" export [--output FILE] backup config + state");
|
|
||||||
println!(" import <file> restore a backup");
|
|
||||||
println!(" shell [list|<name>] show or switch the system shell");
|
|
||||||
println!(" ls [path] · dir [path] list files and directories (Nushell-style table)");
|
|
||||||
println!(" ps list processes");
|
|
||||||
println!(" where <col> <op> <val> filter the last table (ls | where size > 1mb)");
|
|
||||||
println!(" get <col> select a column (ls | get name)");
|
|
||||||
println!(" cd <dir> change directory — Tab completes folders");
|
|
||||||
println!(" help | version | exit this help, the version, quit");
|
|
||||||
println!();
|
|
||||||
println!("pipelines:");
|
|
||||||
println!(" ls | where size > 1mb | get name");
|
|
||||||
println!(" ps | where name =~ am");
|
|
||||||
println!();
|
|
||||||
println!("system gateway:");
|
|
||||||
println!(" any other input runs as a system command in the active shell");
|
|
||||||
println!(" prefix with '!' to force system execution (e.g. '!ls', '!list')");
|
|
||||||
println!(" Tab inserts the common prefix and lists candidates with descriptions");
|
|
||||||
println!();
|
|
||||||
println!("slash commands: /help · /version · /exit · /shell <name>");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
+36
-11
@@ -58,6 +58,26 @@ impl DataTable {
|
|||||||
/// truncated with an ellipsis and the trailing columns are dropped if
|
/// truncated with an ellipsis and the trailing columns are dropped if
|
||||||
/// that is still not enough — every row stays on a single line.
|
/// that is still not enough — every row stays on a single line.
|
||||||
pub fn render(&self, color: bool, max_width: usize) -> String {
|
pub fn render(&self, color: bool, max_width: usize) -> String {
|
||||||
|
self.render_with(None, color, max_width)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render with the colors of a theme (borders dimmed, header row in the
|
||||||
|
/// theme's header style instead of plain bold).
|
||||||
|
pub fn render_themed(
|
||||||
|
&self,
|
||||||
|
theme: &crate::theme::Theme,
|
||||||
|
color: bool,
|
||||||
|
max_width: usize,
|
||||||
|
) -> String {
|
||||||
|
self.render_with(Some(theme), color, max_width)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_with(
|
||||||
|
&self,
|
||||||
|
theme: Option<&crate::theme::Theme>,
|
||||||
|
color: bool,
|
||||||
|
max_width: usize,
|
||||||
|
) -> String {
|
||||||
let max_width = max_width.max(20);
|
let max_width = max_width.max(20);
|
||||||
let headers = self.columns.clone();
|
let headers = self.columns.clone();
|
||||||
if headers.is_empty() {
|
if headers.is_empty() {
|
||||||
@@ -133,13 +153,16 @@ impl DataTable {
|
|||||||
line.push(right);
|
line.push(right);
|
||||||
line
|
line
|
||||||
};
|
};
|
||||||
let row_line = |row: &[String], bold: bool| -> String {
|
let row_line = |row: &[String], header: bool| -> String {
|
||||||
let mut line = String::from("│");
|
let mut line = String::from("│");
|
||||||
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
|
for (i, c) in row.iter().enumerate().take(all_widths.len()) {
|
||||||
line.push(' ');
|
line.push(' ');
|
||||||
let cell = fit_cell(c, all_widths[i]);
|
let cell = fit_cell(c, all_widths[i]);
|
||||||
let rendered = if bold {
|
let rendered = if header {
|
||||||
cell.bold().to_string()
|
match theme {
|
||||||
|
Some(t) if color => t.hdr(&cell),
|
||||||
|
_ => cell.bold().to_string(),
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
cell
|
cell
|
||||||
};
|
};
|
||||||
@@ -149,26 +172,28 @@ impl DataTable {
|
|||||||
}
|
}
|
||||||
line
|
line
|
||||||
};
|
};
|
||||||
let dim = |s: &str| -> String {
|
let edge = |s: &str| -> String {
|
||||||
if color {
|
if !color {
|
||||||
s.dimmed().to_string()
|
return s.to_string();
|
||||||
} else {
|
}
|
||||||
s.to_string()
|
match theme {
|
||||||
|
Some(t) => t.dimmed(s),
|
||||||
|
None => s.dimmed().to_string(),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
out.push_str(&dim(&border('╭', '┬', '╮')));
|
out.push_str(&edge(&border('╭', '┬', '╮')));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
out.push_str(&row_line(&all_headers, true));
|
out.push_str(&row_line(&all_headers, true));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
out.push_str(&dim(&border('├', '┼', '┤')));
|
out.push_str(&edge(&border('├', '┼', '┤')));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
for line in &cells {
|
for line in &cells {
|
||||||
out.push_str(&row_line(line, false));
|
out.push_str(&row_line(line, false));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
}
|
}
|
||||||
out.push_str(&dim(&border('╰', '┴', '╯')));
|
out.push_str(&edge(&border('╰', '┴', '╯')));
|
||||||
out.push('\n');
|
out.push('\n');
|
||||||
out
|
out
|
||||||
}
|
}
|
||||||
|
|||||||
+231
@@ -0,0 +1,231 @@
|
|||||||
|
//! Output color themes: named palettes applied to help, tables, the
|
||||||
|
//! version output and the completion menu.
|
||||||
|
//!
|
||||||
|
//! A theme is a set of SGR (Select Graphic Rendition) parameter strings
|
||||||
|
//! (e.g. "1;38;5;75" = bold + 256-color 75). Painting wraps the text in
|
||||||
|
//! "\x1b[<sgr>m...\x1b[0m". The "mono" theme uses empty sequences and is
|
||||||
|
//! therefore plain text.
|
||||||
|
|
||||||
|
/// One color theme.
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Theme {
|
||||||
|
/// Machine name ("default", "ocean", ...).
|
||||||
|
pub name: &'static str,
|
||||||
|
/// Human-readable title.
|
||||||
|
pub title: &'static str,
|
||||||
|
/// Section headers ("Usage:", table header rows, ...) — bold.
|
||||||
|
pub header: &'static str,
|
||||||
|
/// Accent color: prompts, command names, status "running".
|
||||||
|
pub accent: &'static str,
|
||||||
|
/// Secondary color: types, values, status "installed".
|
||||||
|
pub info: &'static str,
|
||||||
|
/// Faint: borders, search terms, "not-installed".
|
||||||
|
pub dim: &'static str,
|
||||||
|
/// Warnings and "external" status.
|
||||||
|
pub warn: &'static str,
|
||||||
|
/// Errors and "not-installable".
|
||||||
|
pub error: &'static str,
|
||||||
|
/// Success / "ok".
|
||||||
|
pub ok: &'static str,
|
||||||
|
/// Version table keys.
|
||||||
|
pub key: &'static str,
|
||||||
|
/// Version table values.
|
||||||
|
pub value: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub static THEMES: &[Theme] = &[
|
||||||
|
Theme {
|
||||||
|
name: "default",
|
||||||
|
title: "Default (green/cyan)",
|
||||||
|
header: "1;36",
|
||||||
|
accent: "1;32",
|
||||||
|
info: "36",
|
||||||
|
dim: "2",
|
||||||
|
warn: "33",
|
||||||
|
error: "31",
|
||||||
|
ok: "1;32",
|
||||||
|
key: "36",
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
Theme {
|
||||||
|
name: "ocean",
|
||||||
|
title: "Ocean (blues)",
|
||||||
|
header: "1;38;5;75",
|
||||||
|
accent: "1;38;5;81",
|
||||||
|
info: "38;5;117",
|
||||||
|
dim: "2",
|
||||||
|
warn: "38;5;221",
|
||||||
|
error: "38;5;203",
|
||||||
|
ok: "1;38;5;81",
|
||||||
|
key: "38;5;111",
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
Theme {
|
||||||
|
name: "sunset",
|
||||||
|
title: "Sunset (orange/pink)",
|
||||||
|
header: "1;38;5;209",
|
||||||
|
accent: "1;38;5;215",
|
||||||
|
info: "38;5;213",
|
||||||
|
dim: "2",
|
||||||
|
warn: "38;5;221",
|
||||||
|
error: "38;5;203",
|
||||||
|
ok: "1;38;5;215",
|
||||||
|
key: "38;5;213",
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
Theme {
|
||||||
|
name: "forest",
|
||||||
|
title: "Forest (greens)",
|
||||||
|
header: "1;38;5;71",
|
||||||
|
accent: "1;38;5;107",
|
||||||
|
info: "38;5;72",
|
||||||
|
dim: "2",
|
||||||
|
warn: "38;5;179",
|
||||||
|
error: "38;5;167",
|
||||||
|
ok: "1;38;5;107",
|
||||||
|
key: "38;5;108",
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
Theme {
|
||||||
|
name: "dracula",
|
||||||
|
title: "Dracula (purple/green)",
|
||||||
|
header: "1;38;5;141",
|
||||||
|
accent: "1;38;5;84",
|
||||||
|
info: "38;5;117",
|
||||||
|
dim: "2",
|
||||||
|
warn: "38;5;228",
|
||||||
|
error: "38;5;203",
|
||||||
|
ok: "1;38;5;84",
|
||||||
|
key: "38;5;141",
|
||||||
|
value: "1",
|
||||||
|
},
|
||||||
|
Theme {
|
||||||
|
name: "mono",
|
||||||
|
title: "Mono (no colors)",
|
||||||
|
header: "1",
|
||||||
|
accent: "",
|
||||||
|
info: "",
|
||||||
|
dim: "",
|
||||||
|
warn: "",
|
||||||
|
error: "",
|
||||||
|
ok: "1",
|
||||||
|
key: "",
|
||||||
|
value: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/// The theme used when nothing else is configured.
|
||||||
|
pub fn default_theme() -> &'static Theme {
|
||||||
|
&THEMES[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look a theme up by name (case-insensitive).
|
||||||
|
pub fn find(name: &str) -> Option<&'static Theme> {
|
||||||
|
let lower = name.trim().to_lowercase();
|
||||||
|
THEMES.iter().find(|t| t.name.to_lowercase() == lower)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Machine names of every theme, for error messages and help.
|
||||||
|
pub fn names() -> Vec<&'static str> {
|
||||||
|
THEMES.iter().map(|t| t.name).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line per theme: "name - title", for the help output.
|
||||||
|
pub fn describe_all() -> Vec<String> {
|
||||||
|
THEMES
|
||||||
|
.iter()
|
||||||
|
.map(|t| format!("{} - {}", t.name, t.title))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
/// Wrap text in this SGR sequence (empty sequence = plain text).
|
||||||
|
pub fn paint(&self, sgr: &str, text: &str) -> String {
|
||||||
|
if sgr.is_empty() || text.is_empty() {
|
||||||
|
text.to_string()
|
||||||
|
} else {
|
||||||
|
format!("\x1b[{sgr}m{text}\x1b[0m")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hdr(&self, text: &str) -> String {
|
||||||
|
self.paint(self.header, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn acc(&self, text: &str) -> String {
|
||||||
|
self.paint(self.accent, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inf(&self, text: &str) -> String {
|
||||||
|
self.paint(self.info, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn dimmed(&self, text: &str) -> String {
|
||||||
|
self.paint(self.dim, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn warn(&self, text: &str) -> String {
|
||||||
|
self.paint(self.warn, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn err(&self, text: &str) -> String {
|
||||||
|
self.paint(self.error, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ok(&self, text: &str) -> String {
|
||||||
|
self.paint(self.ok, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn key(&self, text: &str) -> String {
|
||||||
|
self.paint(self.key, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn val(&self, text: &str) -> String {
|
||||||
|
self.paint(self.value, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn finds_themes_case_insensitively() {
|
||||||
|
assert_eq!(find("Ocean").unwrap().name, "ocean");
|
||||||
|
assert_eq!(find("default").unwrap().name, "default");
|
||||||
|
assert_eq!(find("DRACULA").unwrap().name, "dracula");
|
||||||
|
assert!(find("blorp").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_is_first() {
|
||||||
|
assert_eq!(default_theme().name, "default");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn mono_theme_paints_nothing() {
|
||||||
|
let mono = find("mono").unwrap();
|
||||||
|
// Bold (structure) survives; colors are gone.
|
||||||
|
assert_eq!(mono.hdr("x"), "[1mx[0m");
|
||||||
|
assert_eq!(mono.acc("x"), "x");
|
||||||
|
assert_eq!(mono.inf("x"), "x");
|
||||||
|
assert_eq!(mono.dimmed("x"), "x");
|
||||||
|
assert_eq!(mono.key("x"), "x");
|
||||||
|
assert_eq!(mono.val("x"), "x");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn paint_wraps_with_ansi() {
|
||||||
|
let t = default_theme();
|
||||||
|
let s = t.acc("ok");
|
||||||
|
assert!(s.starts_with("\x1b[1;32m"));
|
||||||
|
assert!(s.ends_with("\x1b[0m"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_theme_has_a_description() {
|
||||||
|
for t in THEMES {
|
||||||
|
assert!(!t.title.is_empty(), "{}: missing title", t.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user