diff --git a/README.md b/README.md index c90384e..8f8da55 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ Installation Quickstart 1) Download a model (first run can prompt you): - ./target/release/polyscribe --download-models + - In the interactive picker, use Up/Down to navigate, Space to toggle selections, and Enter to confirm. Models are grouped by base (e.g., tiny, base, small). 2) Transcribe a file: - ./target/release/polyscribe -v -o output my_audio.mp3 diff --git a/docs/usage.md b/docs/usage.md index f378280..a0c5c2e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -32,6 +32,7 @@ CLI reference - Number of layers to offload to the GPU when supported. - --download-models - Launch interactive model downloader (lists Hugging Face models; multi-select to download). + - Controls: Use Up/Down to navigate, Space to toggle selections, and Enter to confirm. Models are grouped by base (e.g., tiny, base, small). - --update-models - Verify/update local models by comparing sizes and hashes with the upstream manifest. - -v, --verbose (repeatable) diff --git a/src/models.rs b/src/models.rs index 593c554..c574649 100644 --- a/src/models.rs +++ b/src/models.rs @@ -395,122 +395,61 @@ fn format_model_list(models: &[ModelEntry]) -> String { } fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result> { + // Replaced by cliclack-based multiselect; keep function to preserve signature but delegate. + prompt_select_models_cliclack(models) +} + +fn prompt_select_models_cliclack(models: &[ModelEntry]) -> Result> { if crate::is_no_interaction() || !crate::stdin_is_tty() { // Non-interactive: do not prompt, return empty selection to skip return Ok(Vec::new()); } - // 1) Choose base (tiny, small, medium, etc.) - let mut bases: Vec = Vec::new(); - let mut last = String::new(); - for m in models.iter() { - if m.base != last { - // models are sorted by base; avoid duplicates while preserving order - if !bases.last().map(|b| b == &m.base).unwrap_or(false) { - bases.push(m.base.clone()); + + // Build grouped, aligned labels for selection items (include base prefix for grouping). + let mut item_labels: Vec = Vec::new(); + let mut item_model_indices: Vec = Vec::new(); + + // Compute widths for alignment + let name_width = models.iter().map(|m| m.name.len()).max().unwrap_or(0); + let base_width = models.iter().map(|m| m.base.len()).max().unwrap_or(0); + + for (i, m) in models.iter().enumerate() { + let label = format!( + "{base: = Vec::with_capacity(item_labels.len()); + for (idx, label) in item_labels.iter().cloned().enumerate() { + items.push((item_model_indices[idx], label, String::new())); + } + match cliclack::multiselect::(prompt) + .items(&items) + .interact() + { + Ok(selected_indices) => { + let mut chosen: Vec = Vec::new(); + for mi in selected_indices { + if let Some(m) = models.get(mi) { + chosen.push(m.clone()); + } } - last = m.base.clone(); + Ok(chosen) } - } - if bases.is_empty() { - return Ok(Vec::new()); - } - - // Print base selection via UI - crate::ui::println_above_bars("Available base model families:"); - for (i, b) in bases.iter().enumerate() { - crate::ui::println_above_bars(format!(" {}) {}", i + 1, b)); - } - loop { - let line = match crate::ui::prompt_line("Select base (number or name, 'q' to cancel): ") { - Ok(s) => s, - Err(_) => String::new(), - }; - let s = line.trim(); - if s.eq_ignore_ascii_case("q") - || s.eq_ignore_ascii_case("quit") - || s.eq_ignore_ascii_case("exit") - { - return Ok(Vec::new()); - } - let chosen_base = if let Ok(i) = s.parse::() { - if i >= 1 && i <= bases.len() { - Some(bases[i - 1].clone()) - } else { - None - } - } else if !s.is_empty() { - // accept exact name match (case-insensitive) - bases.iter().find(|b| b.eq_ignore_ascii_case(s)).cloned() - } else { - None - }; - - if let Some(base) = chosen_base { - // 2) Choose sub-type(s) within that base - let filtered: Vec = - models.iter().filter(|m| m.base == base).cloned().collect(); - if filtered.is_empty() { - crate::ui::warn(format!("No models found for base '{base}'.")); - continue; - } - // Reuse the formatter but only for the chosen base list - let listing = format_model_list(&filtered); - crate::ui::println_above_bars(listing); - - // Build index map for filtered list - let mut index_map: Vec = Vec::with_capacity(filtered.len()); - let mut idx = 1usize; - for (pos, _m) in filtered.iter().enumerate() { - index_map.push(pos); - idx += 1; - } - // Second prompt: sub-type selection - loop { - let line2 = crate::ui::prompt_line("Selection: ") - .map_err(|_| anyhow!("Failed to read selection"))?; - let s2 = line2.trim().to_lowercase(); - if s2 == "q" || s2 == "quit" || s2 == "exit" { - return Ok(Vec::new()); - } - let mut selected: Vec = Vec::new(); - if s2 == "all" || s2 == "*" { - selected = (1..idx).collect(); - } else if !s2.is_empty() { - for part in s2.split([',', ' ', ';']) { - let part = part.trim(); - if part.is_empty() { - continue; - } - if let Some((a, b)) = part.split_once('-') { - if let (Ok(ia), Ok(ib)) = (a.parse::(), b.parse::()) { - if ia >= 1 && ib < idx && ia <= ib { - selected.extend(ia..=ib); - } - } - } else if let Ok(i) = part.parse::() { - if i >= 1 && i < idx { - selected.push(i); - } - } - } - } - selected.sort_unstable(); - selected.dedup(); - if selected.is_empty() { - crate::ui::warn("No valid selection. Please try again or 'q' to cancel."); - continue; - } - let chosen: Vec = selected - .into_iter() - .map(|i| filtered[index_map[i - 1]].clone()) - .collect(); - return Ok(chosen); - } - } else { - crate::ui::warn(format!( - "Invalid base selection. Please enter a number from 1-{} or a base name.", - bases.len() - )); + Err(e) => { + // If interaction fails (e.g., not a TTY), return empty to gracefully skip + wlog!("Selection canceled or failed: {}", e); + Ok(Vec::new()) } } }