v0.4.2 : menu de completion Nushell (reedline) + correction des chemins Windows - remplace rustyline par reedline (menu Tab interactif : 1er choix surbrille, Tab navigue, Entree valide, Esc ferme, filtrage en direct, descriptions, couleurs du theme), tokenizer Windows preservant les backslashes pour cd/ls (shell_words mangeait les \), historique history.txt conserve, repli sans terminal, tests + man pages regeneres
release / linux (push) Failing after 24s
release / windows (push) Canceled after 0s
release / macos (push) Canceled after 0s

This commit is contained in:
2026-08-17 13:08:50 -04:00
parent e6e85e2a9e
commit 2c079b0ccd
11 changed files with 1198 additions and 371 deletions
+3
View File
@@ -223,6 +223,9 @@ mod tests {
p.probe_cache_file = dir.join("probe-cache.json");
p.config_dir = Some(dir.join("config"));
app.paths = p;
// The state store keeps the path it was created with: repoint it at
// the sandbox too, or the test reads the developer's real state.
app.state = crate::state::StateStore::new(dir.join("state.json"));
app
}
-89
View File
@@ -235,61 +235,6 @@ pub fn kv_table(
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
// ---------------------------------------------------------------------------
@@ -457,40 +402,6 @@ fn pad(s: &str, width: usize) -> String {
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![
+382 -192
View File
@@ -7,10 +7,13 @@
//! 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 rustyline for line editing: Tab completion (commands, agents, aliases,
//! groups, flags, shell names), command history persisted in the state
//! directory, and graceful fallback to plain line input when no terminal is
//! available.
//! 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};
@@ -23,12 +26,13 @@ use crate::shell::ShellSession;
use crate::theme::Theme;
use anyhow::{anyhow, Result};
use colored::Colorize;
use rustyline::completion::{Completer, Pair};
use rustyline::error::ReadlineError;
use rustyline::highlight::Highlighter;
use rustyline::hint::Hinter;
use rustyline::validate::Validator;
use rustyline::{Context, Helper};
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;
@@ -55,9 +59,6 @@ pub struct AmCompleter {
/// Short descriptions shown next to candidates in the Tab menu
/// (Nushell-style).
descriptions: BTreeMap<String, String>,
/// Active color theme (used by the boxed menu highlight; follows the
/// app theme when it is switched with the REPL 'theme <name>' command).
theme: std::cell::Cell<&'static crate::theme::Theme>,
}
/// One-line descriptions for the Tab completion menu.
@@ -142,15 +143,9 @@ impl AmCompleter {
}
completer.agents.sort();
completer.agents.dedup();
completer.theme.set(app.theme());
completer
}
/// Point the menu at a new theme (after 'theme <name>' in the REPL).
pub fn set_theme(&self, theme: &'static crate::theme::Theme) {
self.theme.set(theme);
}
pub fn from_catalog(
catalog: &crate::catalog::Catalog,
aliases: &BTreeMap<String, String>,
@@ -192,7 +187,6 @@ impl AmCompleter {
descriptions.insert(a.name.clone(), truncate_desc(&desc));
}
Self {
theme: std::cell::Cell::new(crate::theme::default_theme()),
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",
@@ -329,147 +323,186 @@ impl AmCompleter {
}
impl Completer for AmCompleter {
type Candidate = Pair;
fn complete(
&self,
line: &str,
pos: usize,
_ctx: &Context<'_>,
) -> rustyline::Result<(usize, Vec<Pair>)> {
fn complete(&mut self, line: &str, pos: usize) -> Vec<Suggestion> {
let (start, cands) = self.candidates_for(line, pos);
let pairs = if cands.len() < 2 {
// A single match is inserted immediately (no menu).
cands
.into_iter()
.map(|c| Pair {
display: c.clone(),
replacement: c,
})
.collect()
} else {
self.boxed_pairs(&cands)
};
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
cands
.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,
}
.map(|c| Suggestion {
value: c.clone(),
description: self.descriptions.get(&c).cloned(),
span: Span::new(start, pos),
..Default::default()
})
.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;
}
// ---------------------------------------------------------------------------
// 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("❯ ")
}
prefix
}
impl Hinter for AmCompleter {
type Hint = String;
fn hint(&self, _line: &str, _pos: usize, _ctx: &Context<'_>) -> Option<String> {
None
fn render_prompt_right(&self) -> Cow<'_, str> {
Cow::Borrowed("")
}
}
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>(
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,
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.get().frame(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.get().hdr(candidate))
} else {
let bar = self.theme.get().frame("│");
Cow::Owned(format!(
"{}{}{}{}{}",
bar,
self.theme.get().acc(cells[1]),
bar,
cells[2],
bar
))
}
} else if cells.len() == 3 {
// Single-column menu (no descriptions, e.g. the theme
// names): paint the bars and the name.
let bar = self.theme.get().frame("│");
Cow::Owned(format!(
"{}{}{}",
bar,
self.theme.get().acc(cells[1]),
bar
))
} else {
Cow::Borrowed(candidate)
_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);
}
}
_ => Cow::Borrowed(candidate),
}
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(),
}
}
impl Validator for AmCompleter {}
impl Helper for AmCompleter {}
/// 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)
@@ -998,54 +1031,71 @@ pub fn run(app: &App) -> Result<i32> {
}
fn run_with_editor(app: &App, session: &mut ShellSession, sid: &str) -> Result<i32> {
let mut rl: rustyline::Editor<AmCompleter, rustyline::history::FileHistory> =
rustyline::Editor::new()?;
rl.set_helper(Some(AmCompleter::new(app)));
// Nushell-style completion: a single match is inserted immediately;
// otherwise Tab inserts the longest common prefix and the menu lists the
// candidates with their descriptions.
use rustyline::config::{CompletionType, Configurer};
let _ = rl.set_completion_type(CompletionType::List);
let history_path = app.paths.state_file.with_file_name("history.txt");
if rl.load_history(&history_path).is_err() {
// First run: no history yet.
// 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);
match rl.readline("❯ ") {
Ok(line) => {
let trimmed = line.trim().to_string();
if trimmed.is_empty() {
continue;
}
let _ = rl.add_history_entry(trimmed.as_str());
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: repoint the
// completion menu at the new palette.
if let Some(h) = rl.helper_mut() {
h.set_theme(app.theme());
}
match result {
Ok(true) => break,
Ok(false) => {}
Err(e) => app.log.error(&format!("{e:#}")),
}
}
Err(ReadlineError::Interrupted) => continue,
Err(ReadlineError::Eof) => break,
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.save_history(&history_path);
let _ = rl.sync_history();
Ok(0)
}
@@ -1181,7 +1231,10 @@ fn handle_line(
}
return Ok(false);
}
let tokens = shell_words::split(line).map_err(|e| anyhow!("cannot parse command: {e}"))?;
// 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);
}
@@ -1620,8 +1673,7 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
.collect();
let mut table: Option<DataTable> = None;
for part in &parts {
let tokens =
shell_words::split(part).map_err(|e| anyhow!("cannot parse command: {e}"))?;
let tokens = split_command(part)?;
if tokens.is_empty() {
continue;
}
@@ -1681,6 +1733,52 @@ fn eval_pipeline(app: &App, last: &mut Option<DataTable>, line: &str) -> Result<
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));
@@ -1977,18 +2075,79 @@ mod tests {
}
#[test]
fn completion_menu_paints_single_column_bars() {
let c = completer();
c.set_theme(crate::theme::find("ocean").unwrap());
let row = "│ ocean │";
let out = c.highlight_candidate(row, rustyline::CompletionType::List);
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!(
out.contains("\x1b[38;5;75m"),
"bars not painted with the frame color: {out}"
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!(
out.contains("\x1b[1;38;5;81m"),
"name not painted with the accent color: {out}"
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"
);
}
@@ -2051,4 +2210,35 @@ mod tests {
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"]);
}
}