history : reexecution (issues #27 #28) - expansion !! !N !prefix ^old^new dans le REPL + am history --rerun N avec confirmation (shell ou commande am)

This commit is contained in:
2026-08-17 08:05:58 -04:00
parent 4ef89cde72
commit 81c69db17b
6 changed files with 126 additions and 1 deletions
+3
View File
@@ -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 {
+42
View File
@@ -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
+2
View File
@@ -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),
+52
View File
@@ -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);
}
} }
+9
View File
@@ -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
View File
@@ -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.