@@ -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 < ( ) > {
0 commit comments