@@ -214,6 +214,9 @@ pub enum Command {
|
|||||||
/// Maximum number of entries (default: 100)
|
/// Maximum number of entries (default: 100)
|
||||||
#[arg(long, value_name = "N", default_value = "100")]
|
#[arg(long, value_name = "N", default_value = "100")]
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
/// Re-run the Nth most recent entry (with confirmation)
|
||||||
|
#[arg(long, value_name = "N")]
|
||||||
|
rerun: Option<usize>,
|
||||||
},
|
},
|
||||||
/// Show detailed information about one agent
|
/// Show detailed information about one agent
|
||||||
Info {
|
Info {
|
||||||
|
|||||||
@@ -3,6 +3,44 @@
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::history::{self, HistoryEntry};
|
use crate::history::{self, HistoryEntry};
|
||||||
use crate::output::print_json;
|
use crate::output::print_json;
|
||||||
|
use anyhow::{anyhow, Result};
|
||||||
|
use clap::Parser;
|
||||||
|
|
||||||
|
/// Re-run the Nth most recent entry: shell commands go through the
|
||||||
|
/// active shell, am commands are parsed and dispatched like the REPL does.
|
||||||
|
fn rerun_entry(app: &App, n: usize) -> Result<i32> {
|
||||||
|
if n == 0 {
|
||||||
|
return Err(anyhow!("invalid entry number 0 — am history numbers start at 1"));
|
||||||
|
}
|
||||||
|
let entries = history::read_all(app, n);
|
||||||
|
let entry = entries.get(n - 1).cloned().ok_or_else(|| {
|
||||||
|
anyhow!("history entry {n} not found — 'am history' lists them")
|
||||||
|
})?;
|
||||||
|
let question = format!("re-run: {} [y/N]", entry.cmd);
|
||||||
|
if !app.confirm(&question)? {
|
||||||
|
app.log.info("cancelled");
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let cmd = entry.cmd.strip_prefix('!').unwrap_or(&entry.cmd);
|
||||||
|
if entry.kind == "shell" {
|
||||||
|
let session = crate::shell::ShellSession::start(&app.config);
|
||||||
|
return Ok(session.run(cmd)?);
|
||||||
|
}
|
||||||
|
let tokens = shell_words::split(cmd)
|
||||||
|
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let cli = crate::cli::Cli::try_parse_from(std::iter::once("am".to_string()).chain(tokens))
|
||||||
|
.map_err(|e| anyhow!("cannot parse '{cmd}': {e}"))?;
|
||||||
|
match &cli.command {
|
||||||
|
Some(cmd) => execute_command(app, cmd),
|
||||||
|
None => {
|
||||||
|
app.log.info("nothing to re-run (no subcommand)");
|
||||||
|
Ok(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Filter history entries; returns them most recent first.
|
/// Filter history entries; returns them most recent first.
|
||||||
pub fn filter(
|
pub fn filter(
|
||||||
@@ -57,8 +95,12 @@ pub fn run(
|
|||||||
failed: bool,
|
failed: bool,
|
||||||
session: Option<&str>,
|
session: Option<&str>,
|
||||||
limit: usize,
|
limit: usize,
|
||||||
|
rerun: Option<usize>,
|
||||||
) -> Result<i32> {
|
) -> Result<i32> {
|
||||||
history::migrate(app)?;
|
history::migrate(app)?;
|
||||||
|
if let Some(n) = rerun {
|
||||||
|
return rerun_entry(app, n);
|
||||||
|
}
|
||||||
let rows = filter(app, kind, cwd, search, failed, session, limit);
|
let rows = filter(app, kind, cwd, search, failed, session, limit);
|
||||||
if app.json() {
|
if app.json() {
|
||||||
let payload: Vec<serde_json::Value> = rows
|
let payload: Vec<serde_json::Value> = rows
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
|||||||
failed,
|
failed,
|
||||||
session,
|
session,
|
||||||
limit,
|
limit,
|
||||||
|
rerun,
|
||||||
} => history_cmd::run(
|
} => history_cmd::run(
|
||||||
app,
|
app,
|
||||||
kind.as_deref(),
|
kind.as_deref(),
|
||||||
@@ -110,6 +111,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result<i32> {
|
|||||||
*failed,
|
*failed,
|
||||||
session.as_deref(),
|
session.as_deref(),
|
||||||
*limit,
|
*limit,
|
||||||
|
*rerun,
|
||||||
),
|
),
|
||||||
Command::Info { agent } => info_cmd::run(app, agent),
|
Command::Info { agent } => info_cmd::run(app, agent),
|
||||||
Command::Init { force } => init_cmd::run(app, *force),
|
Command::Init { force } => init_cmd::run(app, *force),
|
||||||
|
|||||||
@@ -124,6 +124,40 @@ pub fn migrate(app: &App) -> Result<usize> {
|
|||||||
Ok(count)
|
Ok(count)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Expand a history reference against the most recent entries
|
||||||
|
/// (most recent first): !! (last), !N (the Nth most recent), !prefix (the
|
||||||
|
/// most recent command starting with prefix) and ^old^new (substitution in
|
||||||
|
/// the last command). Returns None when the line is not a reference.
|
||||||
|
pub fn expand(entries: &[HistoryEntry], line: &str) -> Option<String> {
|
||||||
|
let trimmed = line.trim();
|
||||||
|
if trimmed == "!!" {
|
||||||
|
return entries.first().map(|e| e.cmd.clone());
|
||||||
|
}
|
||||||
|
if let Some(rest) = trimmed.strip_prefix('!') {
|
||||||
|
if !rest.is_empty() {
|
||||||
|
if let Ok(n) = rest.parse::<usize>() {
|
||||||
|
if n >= 1 {
|
||||||
|
return entries.get(n - 1).map(|e| e.cmd.clone());
|
||||||
|
}
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.iter()
|
||||||
|
.find(|e| e.cmd.starts_with(rest))
|
||||||
|
.map(|e| e.cmd.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(body) = trimmed.strip_prefix('^') {
|
||||||
|
if let Some((old, new)) = body.split_once('^') {
|
||||||
|
if !old.is_empty() {
|
||||||
|
let last = entries.first()?;
|
||||||
|
return Some(last.cmd.replacen(old, new, 1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// History files of a directory, oldest first.
|
/// History files of a directory, oldest first.
|
||||||
pub fn files(dir: &Path) -> Vec<PathBuf> {
|
pub fn files(dir: &Path) -> Vec<PathBuf> {
|
||||||
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
|
let mut out: Vec<PathBuf> = std::fs::read_dir(dir)
|
||||||
@@ -277,4 +311,22 @@ start group:dev
|
|||||||
Some("20260815_143926".to_string())
|
Some("20260815_143926".to_string())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn expand_history_references() {
|
||||||
|
let entries = vec![
|
||||||
|
HistoryEntry::now("install jcode"),
|
||||||
|
HistoryEntry::now("start group:dev"),
|
||||||
|
HistoryEntry::now("list --all"),
|
||||||
|
];
|
||||||
|
assert_eq!(expand(&entries, "!!").as_deref(), Some("install jcode"));
|
||||||
|
assert_eq!(expand(&entries, "!2").as_deref(), Some("start group:dev"));
|
||||||
|
assert_eq!(expand(&entries, "!9"), None, "out of range");
|
||||||
|
assert_eq!(expand(&entries, "!li").as_deref(), Some("list --all"));
|
||||||
|
assert_eq!(
|
||||||
|
expand(&entries, "^jcode^aider").as_deref(),
|
||||||
|
Some("install aider")
|
||||||
|
);
|
||||||
|
assert_eq!(expand(&entries, "plain line"), None);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1056,6 +1056,14 @@ fn handle_input(
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
// 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).
|
// Slash commands (Hermes-style).
|
||||||
if let Some(rest) = line.strip_prefix('/') {
|
if let Some(rest) = line.strip_prefix('/') {
|
||||||
let (cmd, arg) = match rest.split_once(char::is_whitespace) {
|
let (cmd, arg) = match rest.split_once(char::is_whitespace) {
|
||||||
@@ -1260,6 +1268,7 @@ fn handle_line(
|
|||||||
failed: flag("--failed"),
|
failed: flag("--failed"),
|
||||||
session: opt_value("--session"),
|
session: opt_value("--session"),
|
||||||
limit: opt_value("--limit").and_then(|v| v.parse().ok()).unwrap_or(100),
|
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") },
|
"init" => Command::Init { force: flag("--force") },
|
||||||
"search" => Command::Search {
|
"search" => Command::Search {
|
||||||
|
|||||||
+18
-1
@@ -89,12 +89,29 @@ fn command_runs_and_migrates() {
|
|||||||
let txt = app.paths.state_file.with_file_name("history.txt");
|
let txt = app.paths.state_file.with_file_name("history.txt");
|
||||||
std::fs::write(&txt, "install jcode
|
std::fs::write(&txt, "install jcode
|
||||||
").unwrap();
|
").unwrap();
|
||||||
let code = history_cmd::run(&app, None, None, None, false, None, 100).unwrap();
|
let code = history_cmd::run(&app, None, None, None, false, None, 100, None).unwrap();
|
||||||
assert_eq!(code, 0);
|
assert_eq!(code, 0);
|
||||||
// Migration created the structured file.
|
// Migration created the structured file.
|
||||||
assert!(history::dir(&app).join("migrated.jsonl").exists());
|
assert!(history::dir(&app).join("migrated.jsonl").exists());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rerun_executes_shell_entry_with_yes() {
|
||||||
|
let app = common::test_app("rerun", &["history", "--rerun", "1", "--yes"]);
|
||||||
|
history::record(
|
||||||
|
&app,
|
||||||
|
"s1",
|
||||||
|
&HistoryEntry::now("echo rerun-ok").with_kind("shell".to_string()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let code = history_cmd::run(&app, None, None, None, false, None, 100, Some(1)).unwrap();
|
||||||
|
assert_eq!(code, 0);
|
||||||
|
// Numéro hors plage : erreur claire.
|
||||||
|
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(9)).is_err());
|
||||||
|
// Zéro : erreur claire.
|
||||||
|
assert!(history_cmd::run(&app, None, None, None, false, None, 100, Some(0)).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn fixture_session_file_parses() {
|
fn fixture_session_file_parses() {
|
||||||
// Guard for session_of_file used by the display layer.
|
// Guard for session_of_file used by the display layer.
|
||||||
|
|||||||
Reference in New Issue
Block a user