From 81c69db17be85b776eff09778e3eb987ac3cc23c Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 17 Aug 2026 08:05:58 -0400 Subject: [PATCH] history : reexecution (issues #27 #28) - expansion !! !N !prefix ^old^new dans le REPL + am history --rerun N avec confirmation (shell ou commande am) --- src/cli.rs | 3 +++ src/commands/history_cmd.rs | 42 ++++++++++++++++++++++++++++++ src/commands/mod.rs | 2 ++ src/history.rs | 52 +++++++++++++++++++++++++++++++++++++ src/repl.rs | 9 +++++++ tests/history_test.rs | 19 +++++++++++++- 6 files changed, 126 insertions(+), 1 deletion(-) diff --git a/src/cli.rs b/src/cli.rs index 043f79a..8911e8e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -214,6 +214,9 @@ pub enum Command { /// Maximum number of entries (default: 100) #[arg(long, value_name = "N", default_value = "100")] limit: usize, + /// Re-run the Nth most recent entry (with confirmation) + #[arg(long, value_name = "N")] + rerun: Option, }, /// Show detailed information about one agent Info { diff --git a/src/commands/history_cmd.rs b/src/commands/history_cmd.rs index 63df270..30a4db5 100644 --- a/src/commands/history_cmd.rs +++ b/src/commands/history_cmd.rs @@ -3,6 +3,44 @@ use super::*; use crate::history::{self, HistoryEntry}; 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 { + 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. pub fn filter( @@ -57,8 +95,12 @@ pub fn run( failed: bool, session: Option<&str>, limit: usize, + rerun: Option, ) -> Result { history::migrate(app)?; + if let Some(n) = rerun { + return rerun_entry(app, n); + } let rows = filter(app, kind, cwd, search, failed, session, limit); if app.json() { let payload: Vec = rows diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 1ec7a76..0ba350d 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -102,6 +102,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { failed, session, limit, + rerun, } => history_cmd::run( app, kind.as_deref(), @@ -110,6 +111,7 @@ pub fn execute_command(app: &App, cmd: &Command) -> Result { *failed, session.as_deref(), *limit, + *rerun, ), Command::Info { agent } => info_cmd::run(app, agent), Command::Init { force } => init_cmd::run(app, *force), diff --git a/src/history.rs b/src/history.rs index 6d4ab20..64e0316 100644 --- a/src/history.rs +++ b/src/history.rs @@ -124,6 +124,40 @@ pub fn migrate(app: &App) -> Result { 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 { + 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::() { + 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. pub fn files(dir: &Path) -> Vec { let mut out: Vec = std::fs::read_dir(dir) @@ -277,4 +311,22 @@ start group:dev 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); + } } diff --git a/src/repl.rs b/src/repl.rs index f109326..97191a0 100644 --- a/src/repl.rs +++ b/src/repl.rs @@ -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). if let Some(rest) = line.strip_prefix('/') { let (cmd, arg) = match rest.split_once(char::is_whitespace) { @@ -1260,6 +1268,7 @@ fn handle_line( 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" => Command::Search { diff --git a/tests/history_test.rs b/tests/history_test.rs index c1fbc20..77641d4 100644 --- a/tests/history_test.rs +++ b/tests/history_test.rs @@ -89,12 +89,29 @@ fn command_runs_and_migrates() { let txt = app.paths.state_file.with_file_name("history.txt"); std::fs::write(&txt, "install jcode ").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); // Migration created the structured file. 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] fn fixture_session_file_parses() { // Guard for session_of_file used by the display layer. -- 2.54.0