Files
agent-manager/src/repl.rs
T
bruno 53893f2967
release / linux (push) Successful in 13m24s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s
v0.4.3 : persistance du theme actif choisi
- le REPL 'theme <name>' applique ET sauvegarde le theme (settings.theme dans le fichier de config actif, cree s'il manque) via persist_setting
- correction racine : config::merge ignorait settings.theme - la valeur persistee n etait jamais relue au chargement, le theme par defaut etait toujours reapplique apres un redemarrage
- 'am --theme <name>' reste un one-shot ; config set settings.theme <nom> fonctionne egalement
- aides mises a jour (banniere, help theme)
- tests de persistance + relecture au demarrage, man pages regenerees, version 0.4.3
2026-08-17 15:23:28 -04:00

2252 lines
78 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Interactive shell launched when am is run without a subcommand.
//!
//! The prompt is a Hermes-style gateway between the user and two worlds:
//! the am commands (install, start, stop, ...) and the system shell. Every
//! input that is not an am command is executed as a system command by the
//! selected shell ('shell <name>' switches it), so the prompt doubles as a
//! pass-through to pwsh, cmd, bash, zsh, ... The user's default shell is
//! detected at startup and highlighted in the banner and the status line.
//!
//! Uses reedline (Nushell's line editor) for editing: Nushell-style Tab
//! completion — a single match is inserted immediately, several matches open
//! an interactive menu under the prompt with the first choice highlighted;
//! further Tabs move the highlight to the next choice (Enter validates, Esc
//! closes, arrows navigate, typing filters live). Command history is persisted
//! in the state directory, and there is a graceful fallback to plain line
//! input when no terminal is available.
use crate::app::App;
use crate::cli::{Command, StartArgs};
use crate::commands;
use crate::events::{Event, EventKind};
use crate::nav;
use crate::ps;
use crate::tables::DataTable;
use crate::shell::ShellSession;
use crate::theme::Theme;
use anyhow::{anyhow, Result};
use colored::Colorize;
use nu_ansi_term::{Color, Style};
use reedline::{
default_emacs_keybindings, ColumnarMenu, Completer, Emacs, FileBackedHistory, KeyCode,
KeyModifiers, MenuBuilder, MenuTextStyle, Prompt, PromptEditMode, PromptHistorySearch,
Reedline, ReedlineEvent, ReedlineMenu, Signal, Span, Suggestion,
};
use std::borrow::Cow;
use std::collections::BTreeMap;
use std::io::{IsTerminal, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
// ---------------------------------------------------------------------------
// Tab completion
// ---------------------------------------------------------------------------
/// Completion candidates for the interactive shell.
pub struct AmCompleter {
commands: Vec<&'static str>,
config_sub: Vec<&'static str>,
shells: Vec<&'static str>,
gateway_shells: Vec<&'static str>,
options: Vec<&'static str>,
agents: Vec<String>,
/// Whole catalog (install/info/search only).
catalog_agents: Vec<String>,
aliases: Vec<String>,
groups: Vec<String>,
/// 'profile' subcommands.
profile_sub: Vec<&'static str>,
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
}
/// One-line descriptions for the Tab completion menu.
const COMMAND_DESCRIPTIONS: &[(&str, &str)] = &[
("list", "list installed agents"),
("status", "show agent state"),
("sessions", "list agent sessions"),
("stats", "usage statistics"),
("top", "most used agents"),
("report", "activity report"),
("projects", "project activity"),
("timeline", "unified activity view"),
("log", "read the event journal"),
("logs", "tail an agent log"),
("history", "search command history"),
("init", "generate a project config"),
("alias", "manage aliases"),
("secret", "manage secrets"),
("open", "open the install dir"),
("watch", "supervise an agent"),
("search", "search the catalog"),
("info", "agent details"),
("install", "install an agent"),
("uninstall", "remove an agent"),
("update", "update agents"),
("start", "start an agent"),
("stop", "stop an agent"),
("restart", "restart an agent"),
("run", "run an agent command"),
("doctor", "check the environment"),
("config", "manage the configuration"),
("completion", "generate a completion script"),
("self-update", "update agent-manager"),
("self-uninstall", "remove agent-manager"),
("export", "backup config + state"),
("import", "restore a backup"),
("dashboard", "live TUI dashboard of the fleet"),
("favorite", "mark an agent as a favorite"),
("unfavorite", "remove the favorite star"),
("note", "attach a note to an agent"),
("tag", "tag an agent (personal tags)"),
("untag", "remove a personal tag"),
("tags", "list personal tags"),
("profile", "manage environment profiles"),
("man", "generate man pages"),
("shell", "show or switch the system shell"),
("theme", "show or switch the color theme"),
("ls", "list files and directories (Nushell-style)"),
("dir", "list files and directories (same as ls)"),
("cd", "change directory — Tab completes folders"),
("ps", "list processes"),
("where", "filter the last table — where size > 1mb"),
("get", "select a column — get name"),
("help", "show this help"),
("version", "print the version"),
("exit", "quit the shell"),
];
impl AmCompleter {
pub fn new(app: &App) -> Self {
// Issue #41: Tab proposes the installed agents (managed + external),
// aliases and groups — not the whole catalog.
let installed: std::collections::BTreeSet<String> = app
.state
.all()
.unwrap_or_default()
.keys()
.cloned()
.collect();
let mut completer = Self::from_catalog(
&app.catalog,
&app.config.aliases,
&app.config.groups,
Some(&installed),
);
// External agents found on PATH are launchable too.
let probe = crate::probe::detect_externals(app);
for a in app.catalog.agents() {
if probe.path_of(&a.name).is_some() {
completer.agents.push(a.name.clone());
}
}
completer.agents.sort();
completer.agents.dedup();
completer
}
pub fn from_catalog(
catalog: &crate::catalog::Catalog,
aliases: &BTreeMap<String, String>,
groups: &BTreeMap<String, Vec<String>>,
installed: Option<&std::collections::BTreeSet<String>>,
) -> Self {
let mut agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.filter(|a| installed.map(|set| set.contains(&a.name)).unwrap_or(true))
.map(|a| a.name.clone())
.collect();
agents.sort();
// The whole catalog stays available for install/info/search.
let catalog_agents: Vec<String> = catalog
.agents()
.iter()
.filter(|a| !a.hidden)
.map(|a| a.name.clone())
.collect();
let mut alias_list: Vec<String> = aliases.keys().cloned().collect();
alias_list.sort();
let mut group_list: Vec<String> = groups.keys().map(|g| format!("group:{g}")).collect();
group_list.sort();
let mut descriptions: BTreeMap<String, String> = BTreeMap::new();
for (cmd, desc) in COMMAND_DESCRIPTIONS {
descriptions.insert((*cmd).to_string(), (*desc).to_string());
}
for a in catalog.agents() {
if a.hidden {
continue;
}
let desc = a
.category
.clone()
.or_else(|| a.description.clone())
.unwrap_or_default();
descriptions.insert(a.name.clone(), truncate_desc(&desc));
}
Self {
commands: vec![
"list", "status", "sessions", "stats", "top", "report", "projects", "timeline", "log", "logs", "history", "init", "alias", "secret", "open", "watch", "search", "info", "install", "uninstall", "update",
"start", "stop", "restart", "run", "doctor", "config", "completion",
"self-update", "self-uninstall", "export", "import", "shell", "theme",
"dashboard", "favorite", "unfavorite", "note", "tag", "untag", "tags",
"profile", "man",
"ls", "dir", "cd", "ps", "where", "get", "help", "version", "exit",
],
config_sub: vec!["show", "path", "edit", "validate", "add"],
profile_sub: vec!["list", "show"],
shells: vec!["bash", "zsh", "fish", "powershell", "elvish"],
gateway_shells: vec![
"list", "bash", "zsh", "fish", "powershell", "pwsh", "cmd", "sh", "nu",
"elvish",
],
options: vec![
"--background", "--force", "--purge", "--method", "--all",
"--category", "--args", "--env", "--notify", "--timeout", "--yes",
"--verbose", "--quiet", "--json", "--dry-run", "--no-color",
"--profile", "--installed", "--tag", "--output",
],
agents,
catalog_agents,
aliases: alias_list,
groups: group_list,
descriptions,
}
}
/// Pure completion logic: given the line up to the cursor, returns the
/// replacement start position and the matching candidate strings.
pub fn candidates_for(&self, line: &str, pos: usize) -> (usize, Vec<String>) {
let before = &line[..pos.min(line.len())];
let start = before
.rfind(char::is_whitespace)
.map(|i| i + 1)
.unwrap_or(0);
let word = &before[start..];
let previous: Vec<&str> = before[..start].split_whitespace().collect();
let mut pool: Vec<String> = Vec::new();
if previous.is_empty() {
// Completing the command name itself.
pool = self.commands.iter().map(|s| s.to_string()).collect();
} else {
let first = previous[0];
match first {
"config" => {
pool = self.config_sub.iter().map(|s| s.to_string()).collect();
}
"profile" => {
pool = self.profile_sub.iter().map(|s| s.to_string()).collect();
}
"completion" => {
pool = self.shells.iter().map(|s| s.to_string()).collect();
}
"shell" => {
pool = self.gateway_shells.iter().map(|s| s.to_string()).collect();
}
"theme" => {
pool = crate::theme::names()
.iter()
.map(|s| s.to_string())
.collect();
}
"cd" | "chdir" => {
// Complete directories only — dive one level per Tab.
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, true);
}
"ls" | "dir" => {
// Complete paths (files and directories).
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, false);
}
// Issue #41: operational commands propose installed agents,
// aliases and groups only.
"start" | "stop" | "restart" | "run" | "status" | "logs" | "open"
| "watch" | "favorite" | "unfavorite" | "note" | "tag" | "untag"
| "sessions" | "stats" | "log" | "update" | "uninstall" => {
if word.starts_with("group:") {
pool = self.groups.clone();
} else if previous.len() == 1 {
pool.extend(self.agents.iter().cloned());
pool.extend(self.aliases.iter().cloned());
pool.extend(self.groups.iter().cloned());
} else if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else {
// Extra arguments: only offer flags.
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
"install" | "info" | "search" => {
if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else if previous.len() == 1 {
pool = self.catalog_agents.clone();
pool.extend(self.aliases.iter().cloned());
} else {
pool = self.options.iter().map(|s| s.to_string()).collect();
}
}
_ => {
if word.starts_with('-') {
pool = self.options.iter().map(|s| s.to_string()).collect();
} else {
// Nushell fallback: complete file paths for external
// (system) commands when nothing else applies.
let cwd = current_dir();
pool = crate::nav::path_candidates(&cwd, word, false);
}
}
}
}
let mut cands: Vec<String> = pool
.iter()
.filter(|c| c.starts_with(word))
.cloned()
.collect();
if cands.is_empty() && !word.is_empty() {
let lower = word.to_lowercase();
cands = pool
.into_iter()
.filter(|c| c.to_lowercase().starts_with(&lower))
.collect();
} else if word.is_empty() {
cands = pool;
}
cands.sort();
cands.dedup();
(start, cands)
}
}
impl Completer for AmCompleter {
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
let (start, cands) = self.candidates_for(line, pos);
cands
.into_iter()
.map(|c| Suggestion {
value: c.clone(),
description: self.descriptions.get(&c).cloned(),
span: Span::new(start, pos),
..Default::default()
})
.collect()
}
}
// ---------------------------------------------------------------------------
// Line editor (reedline) and Nushell-style completion menu
// ---------------------------------------------------------------------------
/// The REPL prompt, unchanged from the rustyline era. While a completion
/// menu is open, reedline replaces the (empty) indicator with the menu
/// marker "| ", like Nushell.
struct AmPrompt;
impl Prompt for AmPrompt {
fn render_prompt_left(&self) -> Cow<'_, str> {
Cow::Borrowed("❯ ")
}
fn render_prompt_right(&self) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_indicator(&self, _mode: PromptEditMode) -> Cow<'_, str> {
Cow::Borrowed("")
}
fn render_prompt_multiline_indicator(&self) -> Cow<'_, str> {
Cow::Borrowed(" ")
}
fn render_prompt_history_search_indicator(
&self,
_search: PromptHistorySearch,
) -> Cow<'_, str> {
Cow::Borrowed("(reverse-i-search)'")
}
}
/// Map an ANSI basic color code (30-37, 90-97) onto the nu-ansi-term
/// named palette.
fn basic_color(n: u8) -> Option<Color> {
match n {
30 => Some(Color::Black),
31 => Some(Color::Red),
32 => Some(Color::Green),
33 => Some(Color::Yellow),
34 => Some(Color::Blue),
35 => Some(Color::Purple),
36 => Some(Color::Cyan),
37 => Some(Color::White),
90 => Some(Color::DarkGray),
91 => Some(Color::LightRed),
92 => Some(Color::LightGreen),
93 => Some(Color::LightYellow),
94 => Some(Color::LightBlue),
95 => Some(Color::LightPurple),
96 => Some(Color::LightCyan),
97 => Some(Color::LightGray),
_ => None,
}
}
/// Map one theme SGR string ("1;38;5;81") onto a nu-ansi-term Style so the
/// completion menu can follow the active theme. Unknown parameters are
/// ignored; the empty string (mono theme) yields the default style.
fn sgr_to_style(sgr: &str) -> Style {
let toks: Vec<&str> = sgr.split(';').collect();
let mut style = Style::default();
let mut i = 0;
while i < toks.len() {
match toks[i] {
"1" => style = style.bold(),
"2" => style = style.dimmed(),
"3" => style = style.italic(),
"4" => style = style.underline(),
"7" => style = style.reverse(),
"38" if toks.get(i + 1) == Some(&"5") => {
if let Some(n) = toks.get(i + 2).and_then(|t| t.parse::<u8>().ok()) {
style = style.fg(Color::Fixed(n));
}
i += 2;
}
"39" => style = style.fg(Color::Default),
other => {
if let Some(c) = other.parse::<u8>().ok().and_then(basic_color) {
style = style.fg(c);
}
}
}
i += 1;
}
style
}
/// Completion menu colors derived from the active theme: the highlighted
/// choice is the theme accent in reverse video (like Nushell's default),
/// the other choices are dimmed and the descriptions use the info color.
/// With the mono theme the accent is plain, so only the reverse-video
/// highlight remains.
fn menu_text_style(theme: &crate::theme::Theme, color: bool) -> MenuTextStyle {
let accent = if color {
sgr_to_style(theme.accent)
} else {
Style::default()
};
MenuTextStyle {
selected_text_style: accent.reverse(),
text_style: if color {
sgr_to_style(theme.dim)
} else {
Style::default()
},
description_style: if color {
sgr_to_style(theme.info)
} else {
Style::default()
},
selected_match_style: accent.reverse().underline(),
match_style: Style::default().underline(),
}
}
/// Build the reedline line editor:
///
/// * Nushell-style Tab: the first press opens the completion menu with the
/// first choice highlighted; every further press moves the highlight to
/// the next choice (Enter validates, Esc closes, the arrow keys navigate
/// and typing filters the list live). Shift+Tab moves backwards.
/// * Quick completions: a single match is inserted immediately, without
/// opening the menu.
/// * Command history persisted in the state directory (history.txt).
/// * The completion menu styled with the active theme (the REPL 'theme
/// <name>' command rebuilds the editor so the menu follows).
fn build_line_editor(app: &App, history_path: &std::path::Path) -> Result<Reedline> {
let mut keybindings = default_emacs_keybindings();
keybindings.add_binding(
KeyModifiers::NONE,
KeyCode::Tab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::Menu("completion_menu".to_string()),
ReedlineEvent::MenuNext,
]),
);
keybindings.add_binding(
KeyModifiers::SHIFT,
KeyCode::BackTab,
ReedlineEvent::UntilFound(vec![
ReedlineEvent::Menu("completion_menu".to_string()),
ReedlineEvent::MenuPrevious,
]),
);
let history = FileBackedHistory::with_file(1000, history_path.to_path_buf())
.map_err(|e| anyhow!("cannot open the command history: {e}"))?;
let color = app.color();
let ts = menu_text_style(app.theme(), color);
let menu = ColumnarMenu::default()
.with_name("completion_menu")
.with_text_style(ts.text_style)
.with_selected_text_style(ts.selected_text_style)
.with_description_text_style(ts.description_style)
.with_selected_match_text_style(ts.selected_match_style)
.with_match_text_style(ts.match_style);
Ok(Reedline::create()
.with_completer(Box::new(AmCompleter::new(app)))
.with_quick_completions(true)
.with_history(Box::new(history))
.with_menu(ReedlineMenu::EngineCompleter(Box::new(menu)))
.with_edit_mode(Box::new(Emacs::new(keybindings)))
.with_ansi_colors(color))
}
// ---------------------------------------------------------------------------
// Banner (Hermes-style)
// ---------------------------------------------------------------------------
/// Inner width of the banner box; the block art is exactly this wide.
pub const BOX_WIDTH: usize = 109;
/// One 6-row block glyph of the banner art.
type Glyph = [&'static str; 6];
const G_A: Glyph = [
" █████╗ ",
"██╔══██╗",
"███████║",
"██╔══██║",
"██║ ██║",
"╚═╝ ╚═╝",
];
const G_G: Glyph = [
" ██████╗ ",
"██╔════╝ ",
"██║ ███╗",
"██║ ██║",
"╚██████╔╝",
" ╚═════╝ ",
];
const G_E: Glyph = [
"███████╗",
"██╔════╝",
"█████╗ ",
"██╔══╝ ",
"███████╗",
"╚══════╝",
];
const G_N: Glyph = [
"███╗ ██╗",
"████╗ ██║",
"██╔██╗ ██║",
"██║╚██╗██║",
"██║ ╚████║",
"╚═╝ ╚═══╝",
];
const G_T: Glyph = [
"████████╗",
"╚══██╔══╝",
" ██║ ",
" ██║ ",
" ██║ ",
" ╚═╝ ",
];
const G_M: Glyph = [
"███╗ ███╗",
"████╗ ████║",
"██╔████╔██║",
"██║╚██╔╝██║",
"██║ ╚═╝ ██║",
"╚═╝ ╚═╝",
];
const G_R: Glyph = [
"██████╗ ",
"██╔══██╗",
"██████╔╝",
"██╔══██╗",
"██║ ██║",
"╚═╝ ╚═╝",
];
const G_SP: Glyph = [" ", " ", " ", " ", " ", " "];
/// "AGENT MANAGER" rendered as 6 rows of block glyphs (BOX_WIDTH wide).
pub fn banner_art() -> Vec<String> {
let glyphs: &[&Glyph] = &[
&G_A, &G_G, &G_E, &G_N, &G_T, &G_SP, &G_M, &G_A, &G_N, &G_A, &G_G, &G_E, &G_R,
];
(0..6)
.map(|row| glyphs.iter().map(|g| g[row]).collect::<String>())
.collect()
}
/// The boxed banner: block art, Shell Gateway section (default shell
/// highlighted), the active color theme, command groups and the stats
/// footer. Every color comes from the active theme.
pub fn banner_box(
version: &str,
agents: usize,
installed: usize,
external: usize,
session: &ShellSession,
session_id: &str,
cwd: &str,
theme: &Theme,
color: bool,
) -> String {
let w = BOX_WIDTH;
let mut rows: Vec<String> = Vec::new();
let frame = |s: &str| -> String {
if color {
theme.frame(s)
} else {
s.to_string()
}
};
let inner = |content: String| {
format!(
"{} {} {}",
frame("│"),
pad_visible(content, w),
frame("│")
)
};
let blank = || inner(" ".to_string());
// Top border with the centered header (title in the theme header color,
// border in the theme frame color).
let title = format!(" Agent Manager v{version} · shell gateway ");
let title = if color {
theme.hdr(&title)
} else {
title
};
let fill = w + 2 - visible_len(&title);
let left = fill / 2;
let right = fill - left;
// Paint each segment separately: the title's own reset must not cancel
// the frame color of the dashes on its right.
rows.push(format!(
"{}{}{}",
frame(&format!("╭{}", "─".repeat(left))),
title,
frame(&format!("{}╮", "─".repeat(right))),
));
// Block-art title, painted in the theme accent color.
for art in banner_art() {
rows.push(inner(if color {
theme.acc(&art)
} else {
art
}));
}
// Shell gateway: the user's default shell is highlighted at startup.
rows.push(blank());
rows.push(inner(if color {
theme.hdr("Shell Gateway")
} else {
"Shell Gateway".to_string()
}));
let current = format!(
"◉ {} — {}{}",
green(session.current.name, theme, color),
session.current.title,
if session.is_default() { " (default)" } else { "" }
);
rows.push(inner(format!(" {current}")));
let others: Vec<&str> = session
.available
.iter()
.filter(|s| s.name != session.current.name)
.map(|s| s.name)
.collect();
let avail = if others.is_empty() {
"(no other shell found on PATH)".to_string()
} else {
others.join(" · ")
};
rows.push(inner(dim(&format!(" ⇄ {avail}"), theme, color)));
rows.push(inner(dim(
" system commands run in the active shell — 'shell <name>' switches",
theme,
color,
)));
// Active color theme: the current one highlighted, every other theme
// listed, plus the two ways to switch.
rows.push(blank());
rows.push(inner(if color {
theme.hdr("Theme")
} else {
"Theme".to_string()
}));
let current_theme = format!(
"◉ {} — {}",
green(theme.name, theme, color),
theme.title
);
rows.push(inner(format!(" {current_theme}")));
let theme_others: Vec<&str> = crate::theme::THEMES
.iter()
.map(|t| t.name)
.filter(|n| *n != theme.name)
.collect();
// The full list may not fit one line: wrap it into two ⇄ lines.
let half = theme_others.len().div_ceil(2);
for (i, chunk) in theme_others.chunks(half).enumerate() {
let line = format!(
"{}⇄ {}",
if i == 0 { " " } else { " " },
chunk.join(" · ")
);
rows.push(inner(dim(&line, theme, color)));
}
rows.push(inner(dim(
" 'theme <name>' to switch (saved) · 'am --theme <name>' for a one-shot",
theme,
color,
)));
// am command groups.
rows.push(blank());
rows.push(inner(if color {
theme.hdr("Commands")
} else {
"Commands".to_string()
}));
rows.push(inner(" catalog list · status · search · info".to_string()));
rows.push(inner(
" lifecycle install · uninstall · update · start · stop · restart · run".to_string(),
));
rows.push(inner(
" system doctor · config · completion · self-update · self-uninstall · export · import"
.to_string(),
));
rows.push(inner(
" shell ls · cd · ps · where · get · shell · theme · help · version · exit".to_string(),
));
// Stats footer.
rows.push(blank());
rows.push(inner(dim(
&format!(
"am v{version} · {agents} agents · {external} external · {installed} managed · shell {} · theme {} · /help for commands",
session.current.name,
theme.name
),
theme,
color,
)));
let prefix = format!(" session {session_id} · cwd ");
let budget = w.saturating_sub(visible_len(&prefix) + 2);
rows.push(inner(format!("{prefix}{}", shorten_head(cwd, budget))));
rows.push(inner(yellow(
" ✦ Tip: 'shell <name>' switches the system shell · '!' forces a system command",
theme,
color,
)));
rows.push(frame(&format!("╰{}╯", "─".repeat(w + 2))));
rows.join("\n")
}
/// The full startup banner (box + welcome + tip).
pub fn banner(app: &App, session: &ShellSession, session_id: &str) -> String {
let color = app.color();
let theme = app.theme();
let mut text = banner_box(
env!("CARGO_PKG_VERSION"),
agent_count(app),
installed_count(app),
external_count(app),
session,
session_id,
&current_dir_string(),
theme,
color,
);
text.push('\n');
text.push_str(
"Welcome to Agent Manager! Type an am command or any system command — /help for commands.\n",
);
text.push_str(&format!(
"{} {}\n",
yellow("✦ Tip:", theme, color),
dim(
&format!(
"system commands run in {} — 'shell <name>' switches · '!' forces · 'cd' works in this session",
session.current.name
),
theme,
color,
),
));
text
}
/// Color an emoji glyph with the active theme (style names: green, blue,
/// yellow, cyan).
fn emoji(glyph: &str, style: &str, theme: &Theme, color: bool) -> String {
if !color {
return glyph.to_string();
}
match style {
"green" => theme.acc(glyph),
"blue" => theme.inf(glyph),
"yellow" => theme.warn(glyph),
"cyan" => theme.inf(glyph),
_ => glyph.to_string(),
}
}
/// One-line status shown before every prompt, with colored emojis:
/// ⚙ am v0.2.13 │ 🗂️ cwd │ 73 🤖 · 12 🌐 · 0 📊 │ ⚡ 97ms │ ⏲ 32s
/// The last parameter is the duration of the previous command (None before
/// the first one).
fn print_status(app: &App, started: Instant, last: Option<Duration>) {
let color = app.color();
let theme = app.theme();
let sep = dim(" │ ", theme, color);
let cwd = shorten_head(&current_dir_string(), 30);
let folder = format!(
"{} {}",
emoji("🗂️", "cyan", theme, color),
dim(&cwd, theme, color)
);
let counts = format!(
"{} {} {} {} {} {} {} {}",
dim(&agent_count(app).to_string(), theme, color),
emoji("🤖", "green", theme, color),
dim("·", theme, color),
dim(&external_count(app).to_string(), theme, color),
emoji("🌐", "blue", theme, color),
dim("·", theme, color),
dim(&installed_count(app).to_string(), theme, color),
emoji("📊", "yellow", theme, color),
);
let last_text = match last {
Some(d) if d.as_secs() >= 1 => format_elapsed(d),
Some(d) if d.as_millis() >= 1 => format!("{}ms", d.as_millis()),
Some(d) => format!("{}µs", d.as_micros()),
None => "—".to_string(),
};
let last_part = format!(
"{} {}",
emoji("⚡", "yellow", theme, color),
dim(&last_text, theme, color)
);
let elapsed = dim(
&format!("⏲ {}", format_elapsed(started.elapsed())),
theme,
color,
);
println!(
"{}am v{}{sep}{folder}{sep}{counts}{sep}{last_part}{sep}{elapsed}",
dim(" ⚙ ", theme, color),
env!("CARGO_PKG_VERSION"),
);
}
// ---------------------------------------------------------------------------
// Small display helpers
// ---------------------------------------------------------------------------
fn strip_ansi(s: &str) -> String {
let mut out = String::new();
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for n in chars.by_ref() {
if n == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
fn visible_len(s: &str) -> usize {
strip_ansi(s).chars().count()
}
fn pad_visible(content: String, width: usize) -> String {
let n = visible_len(&content);
if n >= width {
content
} else {
format!("{content}{}", " ".repeat(width - n))
}
}
fn dim(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.dimmed(s)
} else {
s.to_string()
}
}
fn green(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.acc(s)
} else {
s.to_string()
}
}
fn yellow(s: &str, theme: &Theme, color: bool) -> String {
if color {
theme.warn(s)
} else {
s.to_string()
}
}
/// Keep the tail of a long path so it fits a fixed budget.
fn shorten_head(text: &str, budget: usize) -> String {
let n = visible_len(text);
if n <= budget {
return text.to_string();
}
let keep = budget.saturating_sub(1).max(1);
let mut tail: String = text.chars().rev().take(keep).collect();
tail = tail.chars().rev().collect();
format!("…{tail}")
}
fn format_elapsed(d: Duration) -> String {
let s = d.as_secs();
if s < 60 {
format!("{s}s")
} else if s < 3600 {
format!("{}m{:02}s", s / 60, s % 60)
} else {
format!("{}h{:02}m", s / 3600, (s % 3600) / 60)
}
}
/// Unique session stamp, e.g. 20260815_184649_a1b2c3.
pub fn session_id() -> String {
let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.unwrap_or(0);
format!("{stamp}_{:06x}", nanos & 0x00ff_ffff)
}
fn agent_count(app: &App) -> usize {
app.catalog.agents().iter().filter(|a| !a.hidden).count()
}
fn installed_count(app: &App) -> usize {
app.state
.load()
.ok()
.map(|s| s.installed.len())
.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 {
std::env::current_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "?".to_string())
}
fn current_dir() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
/// Keep a catalog description short enough for the completion menu.
fn truncate_desc(desc: &str) -> String {
let one_line = desc.lines().next().unwrap_or(desc);
let mut out: String = one_line.chars().take(64).collect();
if one_line.chars().count() > 64 {
out.push('…');
}
out
}
// ---------------------------------------------------------------------------
// Interactive loop
// ---------------------------------------------------------------------------
pub fn run(app: &App) -> Result<i32> {
// AM_FORCE_REPL lets scripts and tests exercise the REPL without a TTY.
let forced = std::env::var_os("AM_FORCE_REPL").is_some();
if !std::io::stdin().is_terminal() && !forced {
// Piped stdin: print the help instead of hanging.
crate::help::print_general(app);
return Ok(2);
}
let mut session = ShellSession::start(&app.config);
let sid = session_id();
app.emit(
&Event::now(EventKind::Repl)
.with_session(sid.clone())
.with_cwd(Some(current_dir_string())),
);
let _ = crate::sessions::start_repl(app, &sid, Some(current_dir_string()));
if let Err(e) = crate::history::migrate(app) {
app.log.verbose(&format!("history migration skipped: {e:#}"));
}
print!("{}", banner(app, &session, &sid));
let result = match run_with_editor(app, &mut session, &sid) {
Ok(code) => Ok(code),
Err(e) => {
app.log.verbose(&format!(
"line editor unavailable ({e:#}); falling back to plain input"
));
run_plain(app, &mut session, &sid)
}
};
let _ = crate::sessions::finish_repl(app, &sid);
result
}
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
// With piped stdin (tests, scripts, CI) the line editor cannot read
// console keys — reedline would block on the console instead of the
// pipe. Fall back to plain line input (run() handles the fallback).
if !std::io::stdin().is_terminal() {
return Err(anyhow!("stdin is not a terminal"));
}
let history_path = app.paths.state_file.with_file_name("history.txt");
let mut rl = build_line_editor(app, &history_path)?;
let prompt = AmPrompt;
let started = Instant::now();
let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None;
let mut first_read = true;
let mut theme: &'static crate::theme::Theme = app.theme();
loop {
print_status(app, started, last_duration);
let signal = match rl.read_line(&prompt) {
Ok(signal) => signal,
Err(e) => {
// No usable terminal (e.g. AM_FORCE_REPL with piped stdin):
// fall back to plain line input like before.
if first_read {
return Err(anyhow!("line editor unavailable: {e}"));
}
app.log.warn(&format!("input error: {e}"));
break;
}
};
first_read = false;
let line = match signal {
Signal::Success(line) => line,
// Like rustyline: Ctrl-C aborts the current line, Ctrl-D (EOF on
// an empty line) ends the session.
Signal::CtrlC => continue,
Signal::CtrlD => break,
Signal::HostCommand(_) | Signal::ExternalBreak(_) => continue,
// Signal is non-exhaustive: stay in the REPL for future signals.
_ => continue,
};
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}
let t = Instant::now();
let result = handle_input(app, session, &mut last, &trimmed);
last_duration = Some(t.elapsed());
record_history(app, sid, &trimmed, &result, t.elapsed());
// 'theme <name>' may have switched the app theme: rebuild the editor
// so the completion menu follows the new palette (the history is
// file-backed and re-read from disk).
if !std::ptr::eq(theme, app.theme()) {
theme = app.theme();
let _ = rl.sync_history();
match build_line_editor(app, &history_path) {
Ok(new_rl) => rl = new_rl,
Err(e) => app.log.warn(&format!("cannot rebuild the editor: {e:#}")),
}
}
match result {
Ok(true) => break,
Ok(false) => {}
Err(e) => app.log.error(&format!("{e:#}")),
}
}
let _ = rl.sync_history();
Ok(0)
}
/// Append one structured history entry for a REPL command.
fn record_history(
app: &App,
sid: &str,
line: &str,
result: &Result<bool>,
elapsed: std::time::Duration,
) {
let kind = if is_am_command(line.split_whitespace().next().unwrap_or("")) {
"am"
} else {
"shell"
};
let exit_code = if result.is_err() { Some(1) } else { None };
let _ = crate::history::record(
app,
sid,
&crate::history::HistoryEntry::now(line.to_string())
.with_kind(kind.to_string())
.with_cwd(Some(current_dir_string()))
.with_exit_code(exit_code)
.with_duration_ms(elapsed.as_millis() as u64),
);
}
/// Plain fallback when no terminal/line editor is available.
fn run_plain(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let started = Instant::now();
let mut last: Option<DataTable> = None;
let mut last_duration: Option<Duration> = None;
loop {
print_status(app, started, last_duration);
eprint!("❯ ");
std::io::stderr().flush().ok();
let mut line = String::new();
if std::io::stdin().read_line(&mut line).is_err() {
break;
}
let line = line.trim().to_string();
if line.is_empty() {
continue;
}
let t = Instant::now();
let result = handle_input(app, session, &mut last, &line);
last_duration = Some(t.elapsed());
record_history(app, sid, &line, &result, t.elapsed());
match result {
Ok(true) => break,
Ok(false) => {}
Err(e) => app.log.error(&format!("{e:#}")),
}
}
Ok(0)
}
/// Handle one line of input. Returns true when the session should exit.
fn handle_input(
app: &App,
session: &mut ShellSession,
last: &mut Option<DataTable>,
line: &str,
) -> Result<bool> {
match line {
"exit" | "quit" | "q" | "/exit" | "/quit" => return Ok(true),
"help" | "?" | "/help" => {
crate::help::print_general(app);
return Ok(false);
}
"version" | "/version" => {
crate::help::print_version(app);
return Ok(false);
}
"theme" | "/theme" => {
theme_command(app, None);
return Ok(false);
}
_ => {}
}
// History expansion: !! !N !prefix ^old^new (re-execution, issue #27).
if let Some(expanded) = crate::history::expand(&crate::history::read_all(app, 500), line) {
if expanded.trim().is_empty() {
return Ok(false);
}
println!("{expanded}");
return handle_input(app, session, last, &expanded);
}
// Slash commands (Hermes-style).
if let Some(rest) = line.strip_prefix('/') {
let (cmd, arg) = match rest.split_once(char::is_whitespace) {
Some((c, a)) => (c, Some(a.trim())),
None => (rest, None),
};
match cmd {
"shell" => shell_command(session, arg),
"theme" => theme_command(app, 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
.log
.error(&format!("unknown command '/{cmd}' — type '/help'")),
}
return Ok(false);
}
handle_line(app, session, last, line)
}
/// Handle one line of input. Returns true when the session should exit
/// (only after a successful self-uninstall: the binary is gone).
fn handle_line(
app: &App,
session: &mut ShellSession,
last: &mut Option<DataTable>,
line: &str,
) -> Result<bool> {
// Nushell-style pipelines: ls | where ... | get ...
if line.contains('|') {
return eval_pipeline(app, last, line);
}
// '!' forces system execution even for words that look like am commands.
if let Some(rest) = line.strip_prefix('!') {
let rest = rest.trim();
if !rest.is_empty() {
run_system(app, session, rest)?;
}
return Ok(false);
}
// Windows: split with backslashes as literal path separators (the Unix
// shell_words splitter would strip them from 'cd dev\git\...'). Unix
// keeps the classic backslash-escaping semantics.
let tokens = split_command(line)?;
if tokens.is_empty() {
return Ok(false);
}
let cmd = tokens[0].as_str();
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);
}
// theme | theme <name>: list the themes or switch the active one.
if cmd == "theme" {
theme_command(app, rest.first().map(|s| s.as_str()));
return Ok(false);
}
// Session directory change: kept in the am process, not a child shell.
if matches!(cmd, "cd" | "chdir") {
change_dir(rest)?;
return Ok(false);
}
if cmd == "shell" {
shell_command(session, rest.first().map(|s| s.as_str()));
return Ok(false);
}
// Nushell-style directory listing (the system ls stays available as !ls,
// and dir maps to the same table on Windows).
if cmd == "ls" || (cmd == "dir" && cfg!(windows)) {
let table = nav::ls_table(&ls_target(rest)?, app.color())?;
show_table(app, last, table);
return Ok(false);
}
if cmd == "ps" {
show_table(app, last, ps::ps_table());
return Ok(false);
}
if cmd == "where" {
let base = last
.clone()
.ok_or_else(|| anyhow!("'where' needs a table — run ls or ps first (e.g. ls | where size > 1mb)"))?;
if rest.len() < 3 {
anyhow::bail!("usage: where <column> <op> <value>");
}
let filtered =
crate::tables::filter_where(&base, &rest[0], &rest[1], &rest[2..].join(" "))?;
show_table(app, last, filtered);
return Ok(false);
}
if cmd == "get" {
let base = last
.clone()
.ok_or_else(|| anyhow!("'get' needs a table — run ls or ps first (e.g. ls | get name)"))?;
let field = rest
.first()
.ok_or_else(|| anyhow!("usage: get <column>"))?;
let column = crate::tables::get_column(&base, field)?;
show_table(app, last, column);
return Ok(false);
}
// Everything that is not an am command is a system command.
if !is_am_command(cmd) {
run_system(app, session, line)?;
return Ok(false);
}
let need = |what: &str| -> Result<String> {
rest.first()
.cloned()
.ok_or_else(|| anyhow!("missing {what}"))
};
let flag = |f: &str| rest.iter().any(|t| t == f);
let opt_value = |f: &str| {
rest.iter()
.position(|t| t == f)
.and_then(|i| rest.get(i + 1))
.cloned()
};
let method_flag = || opt_value("--method");
let command = match cmd {
"list" => Command::List {
all: flag("--all"),
category: None,
running: flag("--running"),
sort: opt_value("--sort"),
},
"logs" => Command::Logs {
agent: need("agent name")?,
lines: opt_value("--lines").and_then(|v| v.parse().ok()).unwrap_or(20),
follow: flag("--follow"),
},
"open" => Command::Open {
agent: need("agent name")?,
},
"watch" => Command::Watch {
agent: need("agent name")?,
restart: flag("--restart"),
notify: flag("--notify"),
interval: opt_value("--interval").and_then(|v| v.parse().ok()).unwrap_or(5),
},
"secret" => match rest.first().map(|s| s.as_str()) {
Some("set") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Set {
name: rest[1].clone(),
agent: opt_value("--agent").unwrap_or_default(),
value: opt_value("--value").unwrap_or_default(),
}),
Some("unset") if rest.len() >= 2 => Command::Secret(crate::cli::SecretCmd::Unset {
name: rest[1].clone(),
agent: opt_value("--agent").unwrap_or_default(),
}),
Some("list") | None => Command::Secret(crate::cli::SecretCmd::List),
_ => {
app.log.error("usage: secret set NAME --agent A --value V | secret unset NAME --agent A | secret list");
return Ok(false);
}
},
"alias" => match rest.first().map(|s| s.as_str()) {
Some("add") if rest.len() >= 3 => Command::Alias(crate::cli::AliasCmd::Add {
name: rest[1].clone(),
target: rest[2].clone(),
}),
Some("remove") if rest.len() >= 2 => Command::Alias(crate::cli::AliasCmd::Remove {
name: rest[1].clone(),
}),
Some("list") | None => Command::Alias(crate::cli::AliasCmd::List),
_ => {
app.log.error("usage: alias add <name> <target> | alias remove <name> | alias list");
return Ok(false);
}
},
"dashboard" => Command::Dashboard,
"favorite" => Command::Favorite {
agent: need("agent name")?,
},
"unfavorite" => Command::Unfavorite {
agent: need("agent name")?,
},
"note" => Command::Note {
agent: need("agent name")?,
text: rest[1..].to_vec(),
},
"tag" => Command::Tag {
agent: need("agent name")?,
words: rest[1..].to_vec(),
},
"untag" => Command::Untag {
agent: need("agent name")?,
tag: need("tag")?,
},
"tags" => Command::Tags {
agent: rest.first().cloned(),
},
"profile" => match rest.first().map(|s| s.as_str()) {
Some("show") if rest.len() >= 2 => {
Command::Profile(crate::cli::ProfileCmd::Show {
name: rest[1].clone(),
})
}
Some("list") | None => Command::Profile(crate::cli::ProfileCmd::List),
other => {
app.log.error(&format!(
"usage: profile list | profile show <name>{}",
other.map(|o| format!(" (unknown 'profile {o}')")).unwrap_or_default()
));
return Ok(false);
}
},
"man" => Command::Man {
command: rest.first().cloned(),
output: opt_value("--output").map(PathBuf::from),
},
"status" => Command::Status {
agent: rest.first().cloned(),
},
"sessions" => Command::Sessions {
agent: rest.first().cloned(),
project: opt_value("--project"),
status: opt_value("--status"),
show: opt_value("--show"),
resume: opt_value("--resume"),
},
"stats" => Command::Stats {
agent: rest.first().cloned(),
period: opt_value("--period"),
},
"top" => Command::Top {
period: opt_value("--period"),
},
"report" => Command::Report {
last_week: flag("--last-week"),
last_month: flag("--last-month"),
output: opt_value("--output").map(PathBuf::from),
},
"projects" => Command::Projects {
name: rest.first().cloned(),
},
"timeline" => Command::Timeline {
agent: rest.first().cloned(),
project: opt_value("--project"),
since: opt_value("--since"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50),
},
"log" => Command::Log {
agent: rest.first().cloned(),
kind: opt_value("--kind"),
since: opt_value("--since"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(50),
follow: flag("--follow"),
},
"history" => Command::History {
kind: opt_value("--kind"),
cwd: opt_value("--cwd"),
search: opt_value("--search"),
failed: flag("--failed"),
session: opt_value("--session"),
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
rerun: opt_value("--rerun").and_then(|v| v.parse().ok()),
},
"init" => Command::Init { force: flag("--force") },
"search" => {
let words: Vec<String> = rest
.iter()
.filter(|t| !t.starts_with("--"))
.cloned()
.collect();
let kw = words.join(" ");
Command::Search {
keyword: if kw.is_empty() { None } else { Some(kw) },
category: None,
tag: opt_value("--tag"),
}
}
"info" => Command::Info {
agent: need("agent name")?,
},
"start" => Command::Start(StartArgs {
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
..Default::default()
}),
"stop" => Command::Stop {
agent: need("agent name")?,
force: flag("--force"),
timeout: None,
},
"restart" => Command::Restart {
start: StartArgs {
agent: rest.first().cloned(),
background: flag("--background") || flag("-b"),
profile: opt_value("--profile"),
..Default::default()
},
force: flag("--force"),
timeout: None,
},
"install" => Command::Install {
agent: need("agent name")?,
method: method_flag(),
force: flag("--force"),
},
"uninstall" => Command::Uninstall {
agent: need("agent name")?,
purge: flag("--purge"),
},
"update" => {
if flag("--all") {
Command::Update {
agent: None,
all: true,
}
} else {
Command::Update {
agent: rest.first().cloned(),
all: false,
}
}
}
"doctor" => Command::Doctor { fix: flag("--fix") },
"run" => {
let agent = need("agent name")?;
Command::Run {
agent,
args: rest[1..].iter().map(|s| s.as_str().into()).collect(),
}
}
"config" => Command::Config(crate::cli::ConfigCmd::Show),
"completion" => {
let name = need("shell name")?;
let shell: clap_complete::Shell = name
.parse()
.map_err(|e: String| anyhow!("unknown shell '{name}': {e}"))?;
Command::Completion {
shell,
installed: flag("--installed"),
}
}
"self-update" => Command::SelfUpdate {
check: flag("--check"),
to: opt_value("--to").map(PathBuf::from),
},
"self-uninstall" => Command::SelfUninstall,
"export" => Command::Export {
output: opt_value("--output")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("am-export.json")),
},
"import" => Command::Import {
file: need("file")?.into(),
},
other => {
app.log
.error(&format!("unknown command '{other}' — type 'help'"));
return Ok(false);
}
};
// After a successful self-uninstall the executable is scheduled for
// deletion: leave the session instead of pretending nothing happened.
let exit_after = matches!(cmd, "self-uninstall");
commands::execute_command(app, &command)?;
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" | "theme" | "help" | "version" | "exit"
)
}
/// Whether a word is one of the am commands (everything else is executed by
/// the system shell).
fn is_am_command(word: &str) -> bool {
matches!(
word,
"list"
| "status"
| "sessions"
| "stats"
| "top"
| "report"
| "projects"
| "timeline"
| "log"
| "logs"
| "history"
| "init"
| "alias"
| "secret"
| "open"
| "watch"
| "search"
| "info"
| "install"
| "uninstall"
| "update"
| "start"
| "stop"
| "restart"
| "run"
| "doctor"
| "config"
| "completion"
| "self-update"
| "self-uninstall"
| "export"
| "import"
| "dashboard"
| "favorite"
| "unfavorite"
| "note"
| "tag"
| "untag"
| "tags"
| "profile"
| "man"
)
}
/// Resolve the path argument of ls/dir (defaults to the current directory).
fn ls_target(rest: &[String]) -> Result<std::path::PathBuf> {
// 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),
None => current_dir(),
})
}
/// 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) {
let width = crate::output::terminal_width().unwrap_or(120);
print!(
"{}",
table.render_themed(app.theme(), app.color(), width)
);
*last = Some(table);
}
/// Evaluate a Nushell-style pipeline: ls | ps | where | get, in order.
fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<bool> {
let parts: Vec<&str> = line
.split('|')
.map(str::trim)
.filter(|p| !p.is_empty())
.collect();
let mut table: Option<DataTable> = None;
for part in &parts {
let tokens = split_command(part)?;
if tokens.is_empty() {
continue;
}
let cmd = tokens[0].as_str();
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 {
"ls" => {
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
}
"dir" if cfg!(windows) => {
table = Some(nav::ls_table(&ls_target(rest)?, app.color())?);
}
"ps" => {
table = Some(ps::ps_table());
}
"where" => {
let base = table
.as_ref()
.ok_or_else(|| anyhow!("'where' needs a table before it"))?;
if rest.len() < 3 {
anyhow::bail!("usage: where <column> <op> <value>");
}
table = Some(crate::tables::filter_where(
base,
&rest[0],
&rest[1],
&rest[2..].join(" "),
)?);
}
"get" => {
let base = table
.as_ref()
.ok_or_else(|| anyhow!("'get' needs a table before it"))?;
let field = rest.first().ok_or_else(|| anyhow!("usage: get <column>"))?;
table = Some(crate::tables::get_column(base, field)?);
}
other => anyhow::bail!(
"'{other}' cannot be used in a pipeline — use ls, ps, where or get"
),
}
}
if let Some(t) = table {
show_table(app, last, t);
}
Ok(false)
}
/// Split a command line into tokens.
///
/// On Windows, backslashes are literal path separators: 'cd dev\git\...'
/// must reach change_dir intact, and double quotes group tokens ("" escapes
/// a quote), like cmd/PowerShell. The Unix shell_words splitter would eat
/// every backslash as an escape character, breaking Windows paths.
/// On Unix, the classic backslash-escaping rules are kept.
fn split_command(line: &str) -> Result<Vec<String>> {
#[cfg(windows)]
{
let mut tokens: Vec<String> = Vec::new();
let mut cur = String::new();
let mut chars = line.chars().peekable();
let mut in_quotes = false;
while let Some(c) = chars.next() {
match c {
'"' => {
if in_quotes && chars.peek() == Some(&'"') {
chars.next();
cur.push('"');
} else {
in_quotes = !in_quotes;
}
}
c if c.is_whitespace() && !in_quotes => {
if !cur.is_empty() {
tokens.push(std::mem::take(&mut cur));
}
}
c => cur.push(c),
}
}
if in_quotes {
anyhow::bail!("unterminated quote");
}
if !cur.is_empty() {
tokens.push(cur);
}
Ok(tokens)
}
#[cfg(not(windows))]
{
shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))
}
}
/// Run one system command through the active shell (inherited stdio).
fn run_system(app: &App, session: &ShellSession, line: &str) -> Result<()> {
app.log.cmd(&format!("{}: {line}", session.current.name));
let started = std::time::Instant::now();
let code = session.run(line)?;
if code != 0 {
app.log
.error(&format!("command exited with code {code}"));
}
app.emit(
&Event::now(EventKind::Shell)
.with_args(vec![line.to_string()])
.with_exit_code(code)
.with_duration(started.elapsed().as_secs())
.with_cwd(Some(current_dir_string())),
);
Ok(())
}
/// Change the session directory (in-process, unlike shell commands).
fn change_dir(args: &[String]) -> Result<()> {
let target: PathBuf = match args.first().map(|s| s.as_str()) {
None | Some("~") => crate::config::home_dir()
.ok_or_else(|| anyhow!("cannot find the home directory"))?,
Some(p) => crate::config::expand_path(p),
};
std::env::set_current_dir(&target)
.map_err(|e| anyhow!("cd {}: {e}", target.display()))?;
Ok(())
}
/// Show or switch the system shell used by the gateway.
fn shell_command(session: &mut ShellSession, arg: Option<&str>) {
match arg {
None | Some("list") => print_shell_status(session),
Some(name) => match session.switch(name) {
Ok(spec) => println!(
"{}",
format!("shell: {} ({})", spec.name, spec.title).green().bold()
),
Err(e) => eprintln!("{} {}", "error:".red().bold(), e),
},
}
}
/// 'theme' in the REPL: list the themes, or switch with 'theme <name>'.
fn theme_command(app: &App, arg: Option<&str>) {
match arg.map(str::trim).filter(|s| !s.is_empty()) {
None => print_theme_status(app),
Some(name) => match crate::theme::find(name) {
Some(t) => {
app.set_theme(t);
// Persist the choice (settings.theme in the active config
// file) so it survives an exit and restart.
if let Err(e) =
crate::commands::config_cmd::persist_setting(app, "settings.theme", t.name)
{
app.log.error(format!(
"theme applied for this session, but it could not be saved to the config: {e:#}"
));
}
let color = app.color();
println!(
"{} {}",
if color { t.hdr("theme:") } else { "theme:".to_string() },
format!("{} — {}", t.acc(t.name), t.title)
);
}
None => app.log.error(format!(
"unknown theme '{name}' — available: {}",
crate::theme::names().join(", ")
)),
},
}
}
/// List the available themes, highlighting the active one.
fn print_theme_status(app: &App) {
let theme = app.theme();
let color = app.color();
let hdr = |s: &str| -> String {
if color {
theme.hdr(s)
} else {
s.to_string()
}
};
println!("{}", hdr("Theme"));
println!(
" ◉ {} — {}",
theme.acc(theme.name),
theme.title
);
let others: Vec<&str> = crate::theme::THEMES
.iter()
.map(|t| t.name)
.filter(|n| *n != theme.name)
.collect();
println!(" {}", theme.dimmed(&format!("⇄ {}", others.join(" · "))));
println!(
" {}",
theme.dimmed("'theme <name>' to switch (saved to the config) · 'am --theme <name>' for a one-shot")
);
}
fn print_shell_status(session: &ShellSession) {
let others: Vec<&str> = session
.available
.iter()
.filter(|s| s.name != session.current.name)
.map(|s| s.name)
.collect();
println!(
"current shell: {} — {}{}",
session.current.name.bold(),
session.current.title,
if session.is_default() { " (default)" } else { "" }
);
if others.is_empty() {
println!("no other shell found on PATH");
} else {
println!("available: {}", others.join(", "));
}
println!("use 'shell <name>' to switch; set settings.default_shell in the config to make it permanent");
}
/// 'help' in the REPL: the overview, a command/agent topic, the command
/// list, or a --find search — the same Nushell-style help as 'am help'.
fn run_help_command(app: &App, rest: &[String]) -> Result<bool> {
let mut iter = rest.iter();
match iter.next().map(|s| s.as_str()) {
None => crate::help::print_general(app),
Some("--find") => {
let text = iter.next().map(|s| s.as_str()).unwrap_or("");
crate::help::print_find(app, text);
}
Some("-h") | Some("--help") => {
crate::help::print_command(app, "help")?;
}
Some(topic) => {
crate::help::print_command(app, topic)?;
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
fn completer() -> AmCompleter {
let cfg: crate::config::Config =
serde_yaml::from_str(crate::config::DEFAULT_CONFIG).unwrap();
let catalog = crate::catalog::Catalog::new(
cfg.agents,
cfg.aliases.clone(),
cfg.groups.clone(),
);
AmCompleter::from_catalog(&catalog, &cfg.aliases, &cfg.groups, None)
}
fn test_session() -> ShellSession {
ShellSession {
current: crate::shell::find("pwsh").unwrap(),
default: crate::shell::find("pwsh").unwrap(),
available: vec![
crate::shell::find("pwsh").unwrap(),
crate::shell::find("bash").unwrap(),
],
}
}
#[test]
fn completes_commands() {
let c = completer();
let (start, cands) = c.candidates_for("sta", 3);
assert_eq!(start, 0);
assert!(cands.contains(&"start".to_string()));
assert!(cands.contains(&"status".to_string()));
let (_, cands) = c.candidates_for("sh", 2);
assert!(cands.contains(&"shell".to_string()));
let (_, cands) = c.candidates_for("self", 4);
assert!(cands.contains(&"self-update".to_string()));
}
#[test]
fn completes_agent_names_and_aliases() {
let c = completer();
let (start, cands) = c.candidates_for("install claude", 14);
assert_eq!(start, 8);
assert!(cands.contains(&"claude-code".to_string()));
let (_, cands) = c.candidates_for("info cc", 7);
assert!(cands.contains(&"cc".to_string()));
}
#[test]
fn completes_group_selectors() {
let c = completer();
let (_, cands) = c.candidates_for("start group:d", 13);
assert!(!cands.is_empty());
assert!(cands.iter().all(|x| x.starts_with("group:")));
assert!(cands.contains(&"group:dev".to_string()));
}
#[test]
fn completes_flags_after_arguments() {
let c = completer();
let (_, cands) = c.candidates_for("install smelt --dr", 18);
assert!(cands.contains(&"--dry-run".to_string()));
}
#[test]
fn completes_config_subcommands() {
let c = completer();
let (_, cands) = c.candidates_for("config sh", 9);
assert!(cands.contains(&"show".to_string()));
}
#[test]
fn completes_shell_names() {
let c = completer();
let (_, cands) = c.candidates_for("shell pw", 8);
assert!(cands.contains(&"pwsh".to_string()));
let (_, cands) = c.candidates_for("shell po", 8);
assert!(cands.contains(&"powershell".to_string()));
let (_, cands) = c.candidates_for("shell ", 6);
assert!(cands.contains(&"list".to_string()));
}
#[test]
fn banner_art_is_rectangular() {
let rows = banner_art();
assert_eq!(rows.len(), 6);
for row in &rows {
assert_eq!(row.chars().count(), BOX_WIDTH, "row width mismatch: {row:?}");
}
assert!(rows[0].contains("█████"));
}
#[test]
fn banner_box_lines_are_aligned() {
let session = test_session();
let text = banner_box(
"9.9.9",
65,
3,
12,
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
crate::theme::default_theme(),
false,
);
let widths: Vec<usize> = text.lines().map(|l| l.chars().count()).collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}");
assert!(text.contains("Shell Gateway"));
assert!(text.contains("pwsh"));
assert!(text.contains("PowerShell 7"));
assert!(text.contains("(default)"));
assert!(text.contains("bash"));
assert!(text.contains("/help for commands"));
assert!(text.contains("12 external"));
assert!(text.contains("3 managed"));
assert!(text.contains("agent-manager"));
// The splash screen shows the active theme and every other theme.
assert!(text.contains("Theme"));
assert!(text.contains("◉ default"));
assert!(text.contains("theme default"));
assert!(text.contains("--theme <name>"));
for t in crate::theme::THEMES {
if t.name != "default" {
assert!(text.contains(t.name), "banner misses theme {}", t.name);
}
}
}
#[test]
fn banner_box_is_themed_and_still_aligned() {
let session = test_session();
let ocean = crate::theme::find("ocean").unwrap();
let text = banner_box(
"9.9.9",
65,
3,
12,
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
ocean,
true,
);
// ANSI color codes are present with the ocean theme...
assert!(text.contains("\x1b["), "no ANSI in themed banner:\n{text}");
assert!(strip_ansi(&text).contains("◉ ocean"));
// ...and the visible box stays aligned (strip the ANSI codes).
let widths: Vec<usize> = text
.lines()
.map(|l| strip_ansi(l).chars().count())
.collect();
assert!(widths.iter().all(|w| *w == widths[0]), "unaligned box:\n{text}");
}
#[test]
fn completer_produces_suggestions_with_spans() {
// The reedline completer maps (start, candidates) onto suggestions
// whose span covers exactly the typed word.
let mut c = completer();
let suggs = c.complete("inst", 5);
assert!(!suggs.is_empty());
for s in &suggs {
assert_eq!(s.span.start, 0, "completes from the line start");
assert_eq!(s.span.end, 5);
assert!(s.value.starts_with("inst"), "value: {}", s.value);
}
let suggs = c.complete("install cl", 11);
assert_eq!(suggs[0].span.start, 8);
assert_eq!(suggs[0].span.end, 11);
assert!(suggs.iter().all(|s| s.value.starts_with("cl")));
}
#[test]
fn completer_suggestions_carry_descriptions() {
let mut c = completer();
let suggs = c.complete("star", 5);
assert!(suggs.iter().any(|s| {
s.value == "start" && s.description.as_deref() == Some("start an agent")
}));
// Path candidates (cd) have no description: the menu shows them in
// columnar layout instead of the single description column. The
// absolute path keeps the test independent from the process cwd.
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
let line = format!("cd {}", dir.path().display());
let suggs = c.complete(&line, line.len());
let sep = std::path::MAIN_SEPARATOR;
let expected = format!("{}{sep}", dir.path().display());
assert!(
suggs.iter().any(|s| s.value == expected),
"expected {expected}: {suggs:?}"
);
assert!(suggs.iter().all(|s| s.description.is_none()));
}
#[test]
fn sgr_theme_strings_map_to_ansi_term_styles() {
// "1;38;5;81" = bold + 256-color 81 (ocean accent).
let s = sgr_to_style("1;38;5;81");
assert!(s.is_bold, "accent should be bold");
let fg = s.foreground.expect("accent should set a color");
assert_eq!(fg, Color::Fixed(81));
// Basic ANSI colors ("36" = cyan) map onto the named palette.
let s = sgr_to_style("36");
assert_eq!(s.foreground, Some(Color::Cyan));
// The mono theme uses empty SGR strings: default style, no color.
let s = sgr_to_style("");
assert!(!s.is_bold);
assert_eq!(s.foreground, None);
}
#[test]
fn menu_styles_follow_the_theme() {
let ocean = crate::theme::find("ocean").unwrap();
let ts = menu_text_style(ocean, true);
assert!(
ts.selected_text_style.is_reverse,
"the highlighted choice must be in reverse video"
);
assert_eq!(
ts.selected_text_style.foreground,
Some(Color::Fixed(81)),
"the highlighted choice uses the accent color"
);
assert_eq!(
ts.description_style.foreground,
Some(Color::Fixed(117)),
"descriptions use the theme info color"
);
}
#[test]
fn banner_top_border_right_dashes_keep_the_frame_color() {
let session = test_session();
let ocean = crate::theme::find("ocean").unwrap();
let text = banner_box(
"9.9.9",
65,
3,
12,
&session,
"20260815_123456_abcdef",
"/home/alice/dev/agent-manager",
ocean,
true,
);
let top = text.lines().next().unwrap();
// The painted title ends with a reset; the dashes after it must be
// re-painted with the frame color instead of staying default.
let Some(reset_pos) = top.find("\x1b[0m") else {
panic!("no title reset in top border: {top:?}");
};
let tail = &top[reset_pos + "\x1b[0m".len()..];
assert!(tail.contains('─'), "no dashes after the title: {tail:?}");
assert!(
tail.contains("\x1b["),
"right dashes not re-painted with the frame color: {tail:?}"
);
}
#[test]
fn is_am_command_recognizes_known_commands() {
for cmd in [
"list", "status", "search", "info", "install", "uninstall", "update", "start",
"stop", "restart", "run", "doctor", "config", "completion", "self-update",
"self-uninstall", "export", "import",
] {
assert!(is_am_command(cmd), "{cmd}");
}
for cmd in ["ls", "dir", "echo", "Get-ChildItem", "git", "shell", "help", "cd"] {
assert!(!is_am_command(cmd), "{cmd}");
}
}
#[test]
fn format_elapsed_renders_units() {
assert_eq!(format_elapsed(Duration::from_secs(0)), "0s");
assert_eq!(format_elapsed(Duration::from_secs(59)), "59s");
assert_eq!(format_elapsed(Duration::from_secs(65)), "1m05s");
assert_eq!(format_elapsed(Duration::from_secs(3725)), "1h02m");
}
#[test]
fn shorten_head_keeps_the_tail() {
assert_eq!(shorten_head("abc", 10), "abc");
let s = shorten_head("/very/long/path/here", 8);
assert!(s.starts_with('…'));
assert!(s.ends_with("here"));
assert!(s.chars().count() <= 9);
}
#[cfg(windows)]
#[test]
fn split_command_keeps_windows_backslashes() {
// The core regression: 'cd dev\git\...' must not lose its
// backslashes (they are path separators on Windows, not escapes).
let tokens = split_command("cd dev\\git\\Rust\\agent-manager\\").unwrap();
assert_eq!(tokens, vec!["cd", "dev\\git\\Rust\\agent-manager\\"]);
let tokens = split_command("ls dev\\AI\\").unwrap();
assert_eq!(tokens, vec!["ls", "dev\\AI\\"]);
}
#[cfg(windows)]
#[test]
fn split_command_handles_quotes_and_whitespace() {
let tokens = split_command("cd \"my dir\"\\src").unwrap();
assert_eq!(tokens, vec!["cd", "my dir\\src"]);
let tokens = split_command("echo \"a\"\"b\"").unwrap();
assert_eq!(tokens, vec!["echo", "a\"b"]);
assert!(split_command("echo \"unterminated").is_err());
assert!(split_command(" ").unwrap().is_empty());
}
#[cfg(not(windows))]
#[test]
fn split_command_keeps_unix_escaping() {
let tokens = split_command("ls foo\\ bar").unwrap();
assert_eq!(tokens, vec!["ls", "foo bar"]);
let tokens = split_command("cd dev/git").unwrap();
assert_eq!(tokens, vec!["cd", "dev/git"]);
}
}