@@ -2,13 +2,13 @@ use anyhow::Result;
22use clap:: Args ;
33use colored:: Colorize ;
44use console:: { Key , Term } ;
5- use dialoguer:: { FuzzySelect , MultiSelect , Select } ;
5+ use dialoguer:: { Confirm , FuzzySelect , Input , MultiSelect , Select } ;
66
77use super :: require_client;
88use crate :: client:: TrpcClient ;
99use crate :: display;
10- use crate :: types:: { Proposal , ReviewScore } ;
11- use crate :: ui ;
10+ use crate :: types:: { Proposal , ReviewInput , ReviewScore } ;
11+ use crate :: { config , ui } ;
1212
1313// ---------------------------------------------------------------------------
1414// CLI argument types (clap::Args — used directly from main.rs)
@@ -37,6 +37,28 @@ pub struct ListArgs {
3737 pub asc : bool ,
3838}
3939
40+ #[ derive( Args ) ]
41+ pub struct ReviewArgs {
42+ /// Proposal ID
43+ pub id : String ,
44+
45+ /// Content score (1–5)
46+ #[ arg( long, value_parser = clap:: value_parser!( u8 ) . range( 1 ..=5 ) ) ]
47+ pub content : Option < u8 > ,
48+
49+ /// Relevance score (1–5)
50+ #[ arg( long, value_parser = clap:: value_parser!( u8 ) . range( 1 ..=5 ) ) ]
51+ pub relevance : Option < u8 > ,
52+
53+ /// Speaker score (1–5)
54+ #[ arg( long, value_parser = clap:: value_parser!( u8 ) . range( 1 ..=5 ) ) ]
55+ pub speaker : Option < u8 > ,
56+
57+ /// Review comment
58+ #[ arg( long) ]
59+ pub comment : Option < String > ,
60+ }
61+
4062impl ListArgs {
4163 fn has_cli_filters ( & self ) -> bool {
4264 self . status . is_some ( ) || self . format . is_some ( ) || self . sort != "created" || self . asc
@@ -162,8 +184,9 @@ fn avg_rating(p: &Proposal) -> f64 {
162184 . count ( )
163185 . max ( 1 ) ;
164186 #[ allow( clippy:: cast_precision_loss) ]
165- let result = total / count as f64 ;
166- result
187+ {
188+ total / count as f64
189+ }
167190}
168191
169192// ---------------------------------------------------------------------------
@@ -325,6 +348,12 @@ pub async fn fetch_one(client: &TrpcClient, id: &str) -> Result<Proposal> {
325348 client. query ( "proposal.admin.getById" , Some ( & input) ) . await
326349}
327350
351+ pub async fn submit_review ( client : & TrpcClient , input : & ReviewInput ) -> Result < serde_json:: Value > {
352+ client
353+ . mutate ( "proposal.admin.submitReview" , & serde_json:: to_value ( input) ?)
354+ . await
355+ }
356+
328357// ---------------------------------------------------------------------------
329358// Command entry points
330359// ---------------------------------------------------------------------------
@@ -361,6 +390,151 @@ pub async fn get(id: &str, json: bool) -> Result<()> {
361390 Ok ( ( ) )
362391}
363392
393+ pub async fn review ( args : ReviewArgs ) -> Result < ( ) > {
394+ let client = require_client ( ) ?;
395+ let reviewer_name = config:: load ( ) . ok ( ) . and_then ( |c| c. name ) ;
396+
397+ let sp = ui:: spinner ( "Fetching proposal…" ) ;
398+ let proposal = fetch_one ( & client, & args. id ) . await ?;
399+ sp. finish_and_clear ( ) ;
400+
401+ display:: print_proposal_detail ( & proposal) ;
402+ println ! ( ) ;
403+
404+ // If all scores and comment are provided, submit non-interactively
405+ if let ( Some ( content) , Some ( relevance) , Some ( speaker) , Some ( comment) ) =
406+ ( args. content , args. relevance , args. speaker , args. comment )
407+ {
408+ let input = ReviewInput {
409+ id : args. id ,
410+ comment,
411+ score : ReviewScore {
412+ content : f64:: from ( content) ,
413+ relevance : f64:: from ( relevance) ,
414+ speaker : f64:: from ( speaker) ,
415+ } ,
416+ } ;
417+
418+ let sp = ui:: spinner ( "Submitting review…" ) ;
419+ submit_review ( & client, & input) . await ?;
420+ sp. finish_and_clear ( ) ;
421+
422+ println ! ( "Review submitted ({:.0}/15)" , input. score. total( ) ) ;
423+ } else {
424+ prompt_and_submit_review ( & client, & proposal, reviewer_name. as_deref ( ) ) . await ?;
425+ }
426+
427+ Ok ( ( ) )
428+ }
429+
430+ async fn prompt_and_submit_review (
431+ client : & TrpcClient ,
432+ proposal : & Proposal ,
433+ reviewer_name : Option < & str > ,
434+ ) -> Result < ( ) > {
435+ // Find the user's existing review to pre-fill defaults
436+ let existing = reviewer_name. and_then ( |name| {
437+ proposal. reviews . iter ( ) . find ( |r| {
438+ r. reviewer
439+ . as_ref ( )
440+ . is_some_and ( |rev| rev. name . eq_ignore_ascii_case ( name) )
441+ } )
442+ } ) ;
443+
444+ if existing. is_some ( ) {
445+ println ! ( "{}" , "Updating your existing review." . dimmed( ) ) ;
446+ }
447+
448+ let prev_score = existing. and_then ( |r| r. score . as_ref ( ) ) ;
449+ let prev_comment = existing. and_then ( |r| r. comment . as_deref ( ) ) . unwrap_or ( "" ) ;
450+
451+ // Prompt scores (Esc to cancel at any step)
452+ let Some ( content) = prompt_score ( "Content" , score_default ( prev_score, |s| s. content ) ) ? else {
453+ println ! ( "{}" , "Review cancelled." . dimmed( ) ) ;
454+ return Ok ( ( ) ) ;
455+ } ;
456+ let Some ( relevance) = prompt_score ( "Relevance" , score_default ( prev_score, |s| s. relevance ) ) ?
457+ else {
458+ println ! ( "{}" , "Review cancelled." . dimmed( ) ) ;
459+ return Ok ( ( ) ) ;
460+ } ;
461+ let Some ( speaker) = prompt_score ( "Speaker" , score_default ( prev_score, |s| s. speaker ) ) ? else {
462+ println ! ( "{}" , "Review cancelled." . dimmed( ) ) ;
463+ return Ok ( ( ) ) ;
464+ } ;
465+
466+ let comment: String = Input :: new ( )
467+ . with_prompt ( "Comment" )
468+ . with_initial_text ( prev_comment)
469+ . allow_empty ( true )
470+ . interact_text ( ) ?;
471+
472+ // Show summary and confirm
473+ let total = f64:: from ( content) + f64:: from ( relevance) + f64:: from ( speaker) ;
474+ println ! (
475+ "\n Content: {content} Relevance: {relevance} Speaker: {speaker} Total: {total:.0}/15"
476+ ) ;
477+ if !comment. is_empty ( ) {
478+ println ! ( " Comment: {comment}" ) ;
479+ }
480+
481+ if !Confirm :: new ( )
482+ . with_prompt ( "Submit review?" )
483+ . default ( true )
484+ . interact ( ) ?
485+ {
486+ println ! ( "{}" , "Review cancelled." . dimmed( ) ) ;
487+ return Ok ( ( ) ) ;
488+ }
489+
490+ let input = ReviewInput {
491+ id : proposal. id . clone ( ) ,
492+ comment,
493+ score : ReviewScore {
494+ content : f64:: from ( content) ,
495+ relevance : f64:: from ( relevance) ,
496+ speaker : f64:: from ( speaker) ,
497+ } ,
498+ } ;
499+
500+ let sp = ui:: spinner ( "Submitting review…" ) ;
501+ submit_review ( client, & input) . await ?;
502+ sp. finish_and_clear ( ) ;
503+
504+ println ! (
505+ "{} Review submitted ({:.0}/15)" ,
506+ "✔" . green( ) . bold( ) ,
507+ input. score. total( )
508+ ) ;
509+ Ok ( ( ) )
510+ }
511+
512+ fn score_default ( prev : Option < & ReviewScore > , f : impl Fn ( & ReviewScore ) -> f64 ) -> usize {
513+ prev. map_or ( 2 , |s| {
514+ #[ allow( clippy:: cast_possible_truncation, clippy:: cast_sign_loss) ]
515+ let v = f ( s) as usize ;
516+ v. saturating_sub ( 1 ) . min ( 4 )
517+ } )
518+ }
519+
520+ const SCORE_LABELS : & [ & str ] = & [
521+ "1 - Poor" ,
522+ "2 - Fair" ,
523+ "3 - Good" ,
524+ "4 - Very good" ,
525+ "5 - Excellent" ,
526+ ] ;
527+
528+ fn prompt_score ( category : & str , default : usize ) -> Result < Option < u8 > > {
529+ let selection = Select :: new ( )
530+ . with_prompt ( format ! ( "{category} (1–5, esc to cancel)" ) )
531+ . items ( SCORE_LABELS )
532+ . default ( default)
533+ . interact_opt ( ) ?;
534+ #[ allow( clippy:: cast_possible_truncation) ]
535+ Ok ( selection. map ( |idx| ( idx + 1 ) as u8 ) )
536+ }
537+
364538// ---------------------------------------------------------------------------
365539// Output modes
366540// ---------------------------------------------------------------------------
@@ -450,47 +624,84 @@ async fn show_detail_loop(
450624 proposal_ids : & [ & str ] ,
451625 start : usize ,
452626) -> Result < usize > {
453- let term = Term :: stderr ( ) ;
627+ let reviewer_name = config :: load ( ) . ok ( ) . and_then ( |c| c . name ) ;
454628 let mut idx = start;
455629 let total = proposal_ids. len ( ) ;
456630
457631 loop {
458- term. clear_screen ( ) ?;
459- println ! ( "{}" , format!( "[{}/{}]" , idx + 1 , total) . dimmed( ) ) ;
460-
461632 let sp = ui:: spinner ( "Loading…" ) ;
462633 let proposal = fetch_one ( client, proposal_ids[ idx] ) . await ?;
463634 sp. finish_and_clear ( ) ;
464635
465- display:: print_proposal_detail ( & proposal) ;
636+ let content = display:: render_proposal_detail ( & proposal) ;
466637
467- let mut nav_parts = vec ! [ ] ;
638+ // Build nav hints — scroll hints added dynamically by the pager
639+ let mut nav = vec ! [ ] ;
468640 if idx > 0 {
469- nav_parts . push ( "← prev" ) ;
641+ nav . push ( "← prev" ) ;
470642 }
471643 if idx + 1 < total {
472- nav_parts . push ( "→ next" ) ;
644+ nav . push ( "→ next" ) ;
473645 }
474- nav_parts. push ( "any other key to go back" ) ;
475- println ! ( "\n {}" , nav_parts. join( " · " ) . dimmed( ) ) ;
646+ // Use the longest possible hint string for viewport sizing
647+ let mut nav_full = nav. clone ( ) ;
648+ nav_full. extend ( [ "↑↓/jk scroll" , "^u/^d half-page" , "r review" , "q/esc back" ] ) ;
649+ let footer_measure = nav_full. join ( " · " ) ;
476650
477- match term. read_key ( ) ? {
478- Key :: ArrowLeft | Key :: Char ( 'h' | 'k' ) => {
479- idx = idx. saturating_sub ( 1 ) ;
480- }
481- Key :: ArrowRight | Key :: Char ( 'l' | 'j' ) => {
482- if idx + 1 < total {
483- idx += 1 ;
484- }
485- }
486- _ => {
487- term. clear_screen ( ) ?;
488- break ;
651+ let mut pager = ui:: Pager :: new ( & content, & footer_measure) ;
652+
653+ // Build the actual footer shown to the user
654+ if pager. is_scrollable ( ) {
655+ nav. push ( "↑↓/jk scroll" ) ;
656+ nav. push ( "^u/^d half-page" ) ;
657+ }
658+ nav. push ( "r review" ) ;
659+ nav. push ( "q/esc back" ) ;
660+ let footer = nav. join ( " · " ) . dimmed ( ) . to_string ( ) ;
661+
662+ loop {
663+ let header = if pager. is_scrollable ( ) {
664+ format ! (
665+ "[{}/{}] ↕ {}/{}" ,
666+ idx + 1 ,
667+ total,
668+ pager. scroll_offset( ) + 1 ,
669+ pager. line_count( )
670+ )
671+ } else {
672+ format ! ( "[{}/{}]" , idx + 1 , total)
673+ } ;
674+
675+ pager. render ( & header. dimmed ( ) . to_string ( ) , & footer) ?;
676+
677+ match pager. handle_key ( ) ? {
678+ ui:: pager:: Action :: Redraw => { }
679+ ui:: pager:: Action :: Custom ( key) => match key {
680+ Key :: ArrowLeft | Key :: Char ( 'h' ) => {
681+ idx = idx. saturating_sub ( 1 ) ;
682+ break ;
683+ }
684+ Key :: ArrowRight | Key :: Char ( 'l' ) => {
685+ if idx + 1 < total {
686+ idx += 1 ;
687+ }
688+ break ;
689+ }
690+ Key :: Char ( 'r' ) => {
691+ println ! ( ) ;
692+ prompt_and_submit_review ( client, & proposal, reviewer_name. as_deref ( ) )
693+ . await ?;
694+ break ;
695+ }
696+ Key :: Escape | Key :: Char ( 'q' ) => {
697+ pager. clear ( ) ?;
698+ return Ok ( idx) ;
699+ }
700+ _ => { }
701+ } ,
489702 }
490703 }
491704 }
492-
493- Ok ( idx)
494705}
495706
496707// ---------------------------------------------------------------------------
0 commit comments