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
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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"]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user