Skip to content

Commit e594bd0

Browse files
committed
feat: add multiple message generation with --generate flag
Adds -n/--generate <N> (1-5) to produce multiple candidate commit messages. Interactive mode shows a selection menu, --yes auto-selects the first, and --dry-run prints all candidates.
1 parent ba9e99c commit e594bd0

2 files changed

Lines changed: 123 additions & 33 deletions

File tree

src/app.rs

Lines changed: 119 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,9 @@ impl App {
154154
return Err(Error::Cancelled);
155155
}
156156

157-
// Step 5: Generate commit message
157+
// Step 5: Generate commit message(s)
158+
let num_candidates = self.cli.generate;
159+
158160
self.print_status(&format!(
159161
"Contacting {} ({})...",
160162
self.config.provider, self.config.model
@@ -164,54 +166,92 @@ impl App {
164166
debug!(provider = provider.name(), "verifying provider");
165167
provider.verify().await?;
166168

167-
// Setup streaming output
168-
let (tx, mut rx) = mpsc::channel::<String>(64);
169-
170-
// Print streaming tokens (cancellable)
171-
let cancel_for_printer = self.cancel_token.clone();
172-
let print_handle = tokio::spawn(async move {
173-
loop {
174-
tokio::select! {
175-
_ = cancel_for_printer.cancelled() => break,
176-
token = rx.recv() => {
177-
match token {
178-
Some(t) => eprint!("{}", t),
179-
None => break,
169+
let mut candidates: Vec<String> = Vec::new();
170+
171+
for i in 0..num_candidates {
172+
if self.cancel_token.is_cancelled() {
173+
return Err(Error::Cancelled);
174+
}
175+
176+
if num_candidates > 1 {
177+
eprintln!(
178+
"{} Generating candidate {}/{}...",
179+
style("info:").cyan(),
180+
i + 1,
181+
num_candidates
182+
);
183+
} else {
184+
eprintln!("{} Generating...\n", style("info:").cyan());
185+
}
186+
187+
let (tx, mut rx) = mpsc::channel::<String>(64);
188+
189+
// Only stream output for single generation
190+
let show_stream = num_candidates == 1;
191+
let cancel_for_printer = self.cancel_token.clone();
192+
let print_handle = tokio::spawn(async move {
193+
loop {
194+
tokio::select! {
195+
_ = cancel_for_printer.cancelled() => break,
196+
token = rx.recv() => {
197+
match token {
198+
Some(t) if show_stream => eprint!("{}", t),
199+
Some(_) => {} // Suppress streaming for multi-gen
200+
None => break,
201+
}
180202
}
181203
}
182204
}
183-
}
184-
});
205+
});
185206

186-
eprintln!("{} Generating...\n", style("info:").cyan());
207+
let raw_message = provider
208+
.generate(&prompt, tx, self.cancel_token.clone())
209+
.await?;
187210

188-
let raw_message = provider
189-
.generate(&prompt, tx, self.cancel_token.clone())
190-
.await?;
211+
let _ = print_handle.await;
191212

192-
// Wait for printer to finish
193-
let _ = print_handle.await;
213+
if num_candidates == 1 {
214+
eprintln!(); // Newline after streaming
215+
}
216+
217+
if raw_message.trim().is_empty() {
218+
warn!(candidate = i + 1, "empty response from LLM, skipping");
219+
continue;
220+
}
194221

195-
eprintln!(); // Newline after streaming
222+
debug!(
223+
raw_len = raw_message.len(),
224+
candidate = i + 1,
225+
"sanitizing LLM response"
226+
);
227+
match CommitSanitizer::sanitize(&raw_message, &self.config.format) {
228+
Ok(msg) => candidates.push(msg),
229+
Err(e) => {
230+
warn!(candidate = i + 1, error = %e, "failed to sanitize candidate");
231+
}
232+
}
233+
}
196234

197-
if raw_message.trim().is_empty() {
235+
if candidates.is_empty() {
198236
return Err(Error::Provider {
199237
provider: provider.name().into(),
200-
message: "Empty response received".into(),
238+
message: "No valid commit messages generated".into(),
201239
});
202240
}
203241

204-
// Step 6: Sanitize and validate the commit message
205-
debug!(raw_len = raw_message.len(), "sanitizing LLM response");
206-
let message = CommitSanitizer::sanitize(&raw_message, &self.config.format)?;
242+
// Step 6: Select message
243+
let message = if candidates.len() == 1 {
244+
candidates.into_iter().next().unwrap()
245+
} else {
246+
self.select_candidate(&candidates)?
247+
};
207248

208249
// Step 7: Confirm and commit
209250
if self.cli.dry_run {
210251
println!("\n{}", message);
211252
return Ok(());
212253
}
213254

214-
// TTY detection for git hook compatibility
215255
let is_interactive = std::io::stdout().is_terminal() && std::io::stdin().is_terminal();
216256

217257
if !self.cli.yes {
@@ -222,9 +262,12 @@ impl App {
222262
return Ok(());
223263
}
224264

225-
eprintln!("\n{}", style("Generated commit message:").bold());
226-
eprintln!("{}", style(&message).green());
227-
eprintln!();
265+
// For single candidate (already shown via streaming), just confirm
266+
if num_candidates == 1 {
267+
eprintln!("\n{}", style("Generated commit message:").bold());
268+
eprintln!("{}", style(&message).green());
269+
eprintln!();
270+
}
228271

229272
let confirm = Confirm::new()
230273
.with_prompt("Create commit with this message?")
@@ -236,7 +279,6 @@ impl App {
236279
}
237280
}
238281

239-
// Create commit
240282
git.commit(&message).await?;
241283

242284
eprintln!("{} Committed!", style("✓").green().bold());
@@ -362,6 +404,50 @@ impl App {
362404
Ok(())
363405
}
364406

407+
// ─── Candidate Selection ───
408+
409+
fn select_candidate(&self, candidates: &[String]) -> Result<String> {
410+
if self.cli.yes {
411+
return Ok(candidates[0].clone());
412+
}
413+
414+
let is_interactive = std::io::stdout().is_terminal() && std::io::stdin().is_terminal();
415+
416+
if !is_interactive || self.cli.dry_run {
417+
// Non-interactive: print all candidates
418+
for (i, msg) in candidates.iter().enumerate() {
419+
eprintln!("\n{}", style(format!("--- Candidate {} ---", i + 1)).dim());
420+
println!("{}", msg);
421+
}
422+
return Ok(candidates[0].clone());
423+
}
424+
425+
// Interactive: show summary of each and let user pick
426+
eprintln!();
427+
let items: Vec<String> = candidates
428+
.iter()
429+
.enumerate()
430+
.map(|(i, msg)| {
431+
let first_line = msg.lines().next().unwrap_or("(empty)");
432+
format!("[{}] {}", i + 1, first_line)
433+
})
434+
.collect();
435+
436+
let selection = dialoguer::Select::new()
437+
.with_prompt("Pick a commit message")
438+
.items(&items)
439+
.default(0)
440+
.interact()
441+
.map_err(|e| Error::Dialog(e.to_string()))?;
442+
443+
let chosen = &candidates[selection];
444+
eprintln!("\n{}", style("Selected:").bold());
445+
eprintln!("{}", style(chosen).green());
446+
eprintln!();
447+
448+
Ok(chosen.clone())
449+
}
450+
365451
// ─── Hook Commands ───
366452

367453
fn handle_hook(&self, action: &HookAction) -> Result<()> {

src/cli.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@ pub struct Cli {
2929
#[arg(long)]
3030
pub allow_secrets: bool,
3131

32+
/// Generate N candidate messages (default 1, max 5)
33+
#[arg(short = 'n', long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=5))]
34+
pub generate: u8,
35+
3236
/// Show the prompt sent to LLM
3337
#[arg(long)]
3438
pub show_prompt: bool,

0 commit comments

Comments
 (0)