[feat] integrate cliclack for TTY-aware UI, add summaries and intro/outro helpers

This commit is contained in:
2025-08-12 07:30:54 +02:00
parent 4916aa6224
commit 2cc5e49131
5 changed files with 243 additions and 87 deletions

112
Cargo.lock generated
View File

@@ -285,6 +285,20 @@ dependencies = [
"roff",
]
[[package]]
name = "cliclack"
version = "0.3.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c420bdc04c123a2df04d9c5a07289195f00007af6e45ab18f55e56dc7e04b8"
dependencies = [
"console",
"indicatif",
"once_cell",
"strsim",
"textwrap",
"zeroize",
]
[[package]]
name = "cmake"
version = "0.1.54"
@@ -300,6 +314,19 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "console"
version = "0.15.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
dependencies = [
"encode_unicode",
"libc",
"once_cell",
"unicode-width",
"windows-sys 0.59.0",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
@@ -362,6 +389,12 @@ version = "1.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
[[package]]
name = "encode_unicode"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
[[package]]
name = "encoding_rs"
version = "0.8.35"
@@ -814,6 +847,19 @@ dependencies = [
"hashbrown",
]
[[package]]
name = "indicatif"
version = "0.17.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "183b3088984b400f4cfac3620d5e076c84da5364016b4f49473de574b2586235"
dependencies = [
"console",
"number_prefix",
"portable-atomic",
"unicode-width",
"web-time",
]
[[package]]
name = "io-uring"
version = "0.7.9"
@@ -980,6 +1026,12 @@ dependencies = [
"autocfg",
]
[[package]]
name = "number_prefix"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3"
[[package]]
name = "object"
version = "0.36.7"
@@ -1078,6 +1130,7 @@ dependencies = [
"clap",
"clap_complete",
"clap_mangen",
"cliclack",
"libc",
"reqwest",
"serde",
@@ -1088,6 +1141,12 @@ dependencies = [
"whisper-rs",
]
[[package]]
name = "portable-atomic"
version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f84267b20a16ea918e43c6a88433c2d54fa145c92a811b5b047ccbe153674483"
[[package]]
name = "potential_utf"
version = "0.1.2"
@@ -1406,6 +1465,12 @@ version = "1.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]]
name = "smawk"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c388c1b5e93756d0c740965c41e8822f866621d41acbdf6336a6a168f8840c"
[[package]]
name = "socket2"
version = "0.6.0"
@@ -1499,6 +1564,17 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "textwrap"
version = "0.16.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c13547615a44dc9c452a8a534638acdf07120d4b6847c8178705da06306a3057"
dependencies = [
"smawk",
"unicode-linebreak",
"unicode-width",
]
[[package]]
name = "tinystr"
version = "0.8.1"
@@ -1682,6 +1758,18 @@ version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a5f39404a5da50712a4c1eecf25e90dd62b613502b7e925fd4e4d19b5c96512"
[[package]]
name = "unicode-linebreak"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f"
[[package]]
name = "unicode-width"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -1828,6 +1916,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web-time"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
dependencies = [
"js-sys",
"wasm-bindgen",
]
[[package]]
name = "whisper-rs"
version = "0.14.3"
@@ -2147,6 +2245,20 @@ name = "zeroize"
version = "1.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "zerotrie"

View File

@@ -29,6 +29,7 @@ sha2 = "0.10"
# whisper-rs is always used (CPU-only by default); GPU features map onto it
whisper-rs = { git = "https://github.com/tazz4843/whisper-rs" }
libc = "0.2"
cliclack = "0.3"
[dev-dependencies]
tempfile = "3"

View File

@@ -173,50 +173,82 @@ where
}
}
/// Centralized UI helpers (TTY-aware, quiet/verbose-aware)
pub mod ui {
use std::io;
// Prefer cliclack for all user-visible messages to ensure consistent, TTY-aware output.
// Falls back to stderr printing if needed.
/// Startup intro/banner (suppressed when quiet).
pub fn intro(msg: impl AsRef<str>) {
if crate::is_quiet() { return; }
// Use cliclack intro to render a nice banner when TTY
let _ = cliclack::intro(msg.as_ref());
}
/// Print an informational line (suppressed when quiet).
pub fn info(msg: impl AsRef<str>) {
if crate::is_quiet() { return; }
let _ = cliclack::log::info(msg.as_ref());
}
/// Print a warning (always printed).
pub fn warn(msg: impl AsRef<str>) {
// cliclack provides a warning-level log utility
let _ = cliclack::log::warning(msg.as_ref());
}
/// Print an error (always printed).
pub fn error(msg: impl AsRef<str>) {
let _ = cliclack::log::error(msg.as_ref());
}
/// Print a line above any progress bars (maps to cliclack log; synchronized).
pub fn println_above_bars(msg: impl AsRef<str>) {
if crate::is_quiet() { return; }
// cliclack logs are synchronized with its spinners/bars
let _ = cliclack::log::info(msg.as_ref());
}
/// Final outro/summary printed below any progress indicators (suppressed when quiet).
pub fn outro(msg: impl AsRef<str>) {
if crate::is_quiet() { return; }
let _ = cliclack::outro(msg.as_ref());
}
/// Prompt the user (TTY-aware via cliclack) and read a line from stdin. Returns the raw line with trailing newline removed.
pub fn prompt_line(prompt: &str) -> io::Result<String> {
// Route prompt through cliclack to keep consistent styling and avoid direct eprint!/println!
let _ = cliclack::log::info(prompt);
let mut s = String::new();
io::stdin().read_line(&mut s)?;
Ok(s)
}
}
/// Logging macros and helpers
/// Log an error to stderr (always printed). Recommended for user-visible errors.
/// Log an error using the UI helper (always printed). Recommended for user-visible errors.
#[macro_export]
macro_rules! elog {
($($arg:tt)*) => {{
eprintln!("ERROR: {}", format!($($arg)*));
}}
}
/// Internal helper macro used by other logging macros to centralize the
/// common behavior: build formatted message, check quiet/verbose flags,
/// and print to stderr with a label.
#[macro_export]
macro_rules! log_with_level {
($label:expr, $min_lvl:expr, $always:expr, $($arg:tt)*) => {{
let should_print = if $always {
true
} else if let Some(minv) = $min_lvl {
!$crate::is_quiet() && $crate::verbose_level() >= minv
} else {
!$crate::is_quiet()
};
if should_print {
eprintln!("{}: {}", $label, format!($($arg)*));
}
$crate::ui::error(format!($($arg)*));
}}
}
/// Log a warning to stderr (printed even in quiet mode).
/// Log a warning using the UI helper (printed even in quiet mode).
#[macro_export]
macro_rules! wlog {
($($arg:tt)*) => {{ $crate::log_with_level!("WARN", None, true, $($arg)*); }}
($($arg:tt)*) => {{
$crate::ui::warn(format!($($arg)*));
}}
}
/// Log an informational line to stderr unless quiet mode is enabled.
/// Log an informational line using the UI helper unless quiet mode is enabled.
#[macro_export]
macro_rules! ilog {
($($arg:tt)*) => {{ $crate::log_with_level!("INFO", None, false, $($arg)*); }}
($($arg:tt)*) => {{
if !$crate::is_quiet() { $crate::ui::info(format!($($arg)*)); }
}}
}
/// Log a debug/trace line when verbose level is at least the given level (u8).
#[macro_export]
macro_rules! dlog {
($lvl:expr, $($arg:tt)*) => {{
$crate::log_with_level!(&format!("DEBUG{}", &$lvl), Some($lvl), false, $($arg)*);
if !$crate::is_quiet() && $crate::verbose_level() >= $lvl { $crate::ui::info(format!("DEBUG{}: {}", $lvl, format!($($arg)*))); }
}}
}
@@ -230,7 +262,6 @@ use anyhow::{Context, Result, anyhow};
use chrono::Local;
use std::env;
use std::fs::create_dir_all;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -462,10 +493,7 @@ pub fn find_model_file() -> Result<PathBuf> {
"No models available and interactive mode is disabled. Please set WHISPER_MODEL or run with --download-models."
));
}
eprint!("Would you like to download models now? [Y/n]: ");
io::stderr().flush().ok();
let mut input = String::new();
io::stdin().read_line(&mut input).ok();
let input = crate::ui::prompt_line("Would you like to download models now? [Y/n]: ").unwrap_or_default();
let ans = input.trim().to_lowercase();
if ans.is_empty() || ans == "y" || ans == "yes" {
if let Err(e) = models::run_interactive_model_downloader() {
@@ -519,16 +547,12 @@ pub fn find_model_file() -> Result<PathBuf> {
}
}
eprintln!("Multiple Whisper models found in {}:", models_dir.display());
crate::ui::println_above_bars(format!("Multiple Whisper models found in {}:", models_dir.display()));
for (i, p) in candidates.iter().enumerate() {
eprintln!(" {}) {}", i + 1, p.display());
crate::ui::println_above_bars(format!(" {}) {}", i + 1, p.display()));
}
eprint!("Select model by number [1-{}]: ", candidates.len());
io::stderr().flush().ok();
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.context("Failed to read selection")?;
let input = crate::ui::prompt_line(&format!("Select model by number [1-{}]: ", candidates.len()))
.map_err(|_| anyhow!("Failed to read selection"))?;
let sel: usize = input
.trim()
.parse()

View File

@@ -142,25 +142,18 @@ fn prompt_speaker_name_for_path(path: &Path, default_name: &str, enabled: bool)
.and_then(|s| s.to_str())
.map(|s| s.to_string())
.unwrap_or_else(|| path.to_string_lossy().to_string());
eprint!(
let buf = polyscribe::ui::prompt_line(&format!(
"Enter speaker name for {display_owned} [default: {default_name}]: "
);
io::stderr().flush().ok();
let mut buf = String::new();
match io::stdin().read_line(&mut buf) {
Ok(_) => {
let raw = buf.trim();
if raw.is_empty() {
return default_name.to_string();
}
let sanitized = sanitize_speaker_name(raw);
if sanitized.is_empty() {
default_name.to_string()
} else {
sanitized
}
}
Err(_) => default_name.to_string(),
)).unwrap_or_default();
let raw = buf.trim();
if raw.is_empty() {
return default_name.to_string();
}
let sanitized = sanitize_speaker_name(raw);
if sanitized.is_empty() {
default_name.to_string()
} else {
sanitized
}
}
@@ -217,6 +210,7 @@ where
}
fn run() -> Result<()> {
let _t0 = std::time::Instant::now();
// Parse CLI
let args = Args::parse();
@@ -225,6 +219,9 @@ fn run() -> Result<()> {
polyscribe::set_quiet(args.quiet);
polyscribe::set_no_interaction(args.no_interaction);
// Startup banner via UI (TTY-aware through cliclack), suppressed when quiet
polyscribe::ui::intro(format!("PolyScribe v{}", env!("CARGO_PKG_VERSION")));
// Handle auxiliary subcommands that write to stdout and exit early
if let Some(aux) = &args.aux {
use clap::CommandFactory;
@@ -266,6 +263,10 @@ fn run() -> Result<()> {
polyscribe::dlog!(1, "Using backend: {:?}", sel.chosen);
// If requested, run the interactive model downloader first. If no inputs were provided, exit after downloading.
let mut summary_inputs_total: usize = 0;
let mut summary_audio_count: usize = 0;
let mut summary_json_count: usize = 0;
let mut summary_segments_total: usize = 0;
if args.download_models {
if let Err(e) = polyscribe::models::run_interactive_model_downloader() {
polyscribe::elog!("Model downloader failed: {:#}", e);
@@ -290,6 +291,7 @@ fn run() -> Result<()> {
// Determine inputs and optional output path
polyscribe::dlog!(1, "Parsed {} input(s)", args.inputs.len());
let mut inputs = args.inputs;
summary_inputs_total = inputs.len();
let mut output_path = args.output;
if output_path.is_none() && inputs.len() >= 2 {
if let Some(last) = inputs.last().cloned() {
@@ -353,6 +355,7 @@ fn run() -> Result<()> {
// Collect entries per file and extend merged
let mut entries: Vec<OutputEntry> = Vec::new();
if is_audio_file(path) {
summary_audio_count += 1;
// Progress log to stderr (suppressed by -q); avoid partial lines
polyscribe::ilog!("Processing file: {} ...", path.display());
let res = with_quiet_stdio_if_needed(args.quiet, || {
@@ -372,6 +375,7 @@ fn run() -> Result<()> {
}
}
} else if is_json_file(path) {
summary_json_count += 1;
let mut buf = String::new();
File::open(path)
.with_context(|| format!("Failed to open: {input_path}"))?
@@ -409,6 +413,7 @@ fn run() -> Result<()> {
for (i, e) in entries.iter_mut().enumerate() {
e.id = i as u64;
}
summary_segments_total += entries.len();
// Write separate outputs to out_dir
let out = OutputRoot {
@@ -498,6 +503,7 @@ fn run() -> Result<()> {
let mut buf = String::new();
if is_audio_file(path) {
summary_audio_count += 1;
// Progress log to stderr (suppressed by -q)
polyscribe::ilog!("Processing file: {} ...", path.display());
let res = with_quiet_stdio_if_needed(args.quiet, || {
@@ -520,6 +526,7 @@ fn run() -> Result<()> {
}
}
} else if is_json_file(path) {
summary_json_count += 1;
File::open(path)
.with_context(|| format!("Failed to open: {}", input_path))?
.read_to_string(&mut buf)
@@ -559,6 +566,7 @@ fn run() -> Result<()> {
e.id = i as u64;
}
let out = OutputRoot { items: entries };
summary_segments_total = out.items.len();
if let Some(path) = output_path {
let base_path = Path::new(&path);
@@ -636,6 +644,7 @@ fn run() -> Result<()> {
// Collect entries per file
let mut entries: Vec<OutputEntry> = Vec::new();
if is_audio_file(path) {
summary_audio_count += 1;
// Progress log to stderr (suppressed by -q)
polyscribe::ilog!("Processing file: {} ...", path.display());
let res = with_quiet_stdio_if_needed(args.quiet, || {
@@ -655,6 +664,7 @@ fn run() -> Result<()> {
}
}
} else if is_json_file(path) {
summary_json_count += 1;
let mut buf = String::new();
File::open(path)
.with_context(|| format!("Failed to open: {input_path}"))?
@@ -692,6 +702,7 @@ fn run() -> Result<()> {
for (i, e) in entries.iter_mut().enumerate() {
e.id = i as u64;
}
summary_segments_total += entries.len();
let out = OutputRoot { items: entries };
if let Some(dir) = &out_dir {
@@ -736,6 +747,20 @@ fn run() -> Result<()> {
}
}
// Final summary (TTY-aware via UI), only when not quiet
if !polyscribe::is_quiet() {
let elapsed = _t0.elapsed();
let secs = elapsed.as_secs_f32();
let mut out = String::new();
out.push_str("Summary:\n");
out.push_str(&format!("{:<12} {:>8}\n", "Files:", summary_inputs_total));
out.push_str(&format!("{:<12} {:>8}\n", "Audio:", summary_audio_count));
out.push_str(&format!("{:<12} {:>8}\n", "JSON:", summary_json_count));
out.push_str(&format!("{:<12} {:>8}\n", "Segments:", summary_segments_total));
out.push_str(&format!("{:<12} {:>8.2}s\n", "Time:", secs));
polyscribe::ui::outro(out);
}
Ok(())
}

View File

@@ -5,7 +5,7 @@
use std::collections::BTreeMap;
use std::env;
use std::fs::{File, create_dir_all};
use std::io::{self, Read, Write};
use std::io::{Read, Write};
use std::path::Path;
use std::time::Duration;
@@ -326,8 +326,8 @@ fn fetch_all_models(client: &Client) -> Result<Vec<ModelEntry>> {
match hf_fetch_repo_models(client, "akashmjn/tinydiarize-whisper.cpp") {
Ok(v) => v,
Err(e) => {
ilog!(
"Warning: failed to fetch optional repo akashmjn/tinydiarize-whisper.cpp: {:#}",
wlog!(
"Failed to fetch optional repo akashmjn/tinydiarize-whisper.cpp: {:#}",
e
);
Vec::new()
@@ -413,18 +413,16 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntr
return Ok(Vec::new());
}
// Print base selection on stderr
eprintln!("Available base model families:");
// Print base selection via UI
crate::ui::println_above_bars("Available base model families:");
for (i, b) in bases.iter().enumerate() {
eprintln!(" {}) {}", i + 1, b);
crate::ui::println_above_bars(format!(" {}) {}", i + 1, b));
}
loop {
eprint!("Select base (number or name, 'q' to cancel): ");
io::stderr().flush().ok();
let mut line = String::new();
io::stdin()
.read_line(&mut line)
.context("Failed to read base selection")?;
let mut 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")
@@ -450,12 +448,12 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntr
let filtered: Vec<ModelEntry> =
models.iter().filter(|m| m.base == base).cloned().collect();
if filtered.is_empty() {
eprintln!("No models found for base '{base}'.");
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);
eprint!("{listing}");
crate::ui::println_above_bars(listing);
// Build index map for filtered list
let mut index_map: Vec<usize> = Vec::with_capacity(filtered.len());
@@ -466,12 +464,8 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntr
}
// Second prompt: sub-type selection
loop {
eprint!("Selection: ");
io::stderr().flush().ok();
let mut line2 = String::new();
io::stdin()
.read_line(&mut line2)
.context("Failed to read selection")?;
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());
@@ -501,7 +495,7 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntr
selected.sort_unstable();
selected.dedup();
if selected.is_empty() {
eprintln!("No valid selection. Please try again or 'q' to cancel.");
crate::ui::warn("No valid selection. Please try again or 'q' to cancel.");
continue;
}
let chosen: Vec<ModelEntry> = selected
@@ -511,10 +505,10 @@ fn prompt_select_models_two_stage(models: &[ModelEntry]) -> Result<Vec<ModelEntr
return Ok(chosen);
}
} else {
eprintln!(
crate::ui::warn(format!(
"Invalid base selection. Please enter a number from 1-{} or a base name.",
bases.len()
);
));
}
}
}
@@ -591,8 +585,8 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) ->
}
}
Err(e) => {
qlog!(
"Warning: failed to hash existing {}: {}. Will re-download to ensure correctness.",
wlog!(
"Failed to hash existing {}: {}. Will re-download to ensure correctness.",
final_path.display(),
e
);
@@ -618,8 +612,8 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) ->
}
}
Err(e) => {
qlog!(
"Warning: failed to stat existing {}: {}. Will re-download to ensure correctness.",
wlog!(
"Failed to stat existing {}: {}. Will re-download to ensure correctness.",
final_path.display(),
e
);
@@ -723,8 +717,8 @@ fn download_one_model(client: &Client, models_dir: &Path, entry: &ModelEntry) ->
));
}
} else {
qlog!(
"Warning: no SHA-256 available for {}. Skipping verification.",
wlog!(
"No SHA-256 available for {}. Skipping verification.",
entry.name
);
}
@@ -826,7 +820,7 @@ pub fn update_local_models() -> Result<()> {
}
}
Err(e) => {
qlog!("Warning: failed hashing {}: {}. Re-downloading.", fname, e);
wlog!("Failed hashing {}: {}. Re-downloading.", fname, e);
}
}
download_one_model(&client, models_dir, remote)?;
@@ -839,7 +833,7 @@ pub fn update_local_models() -> Result<()> {
download_one_model(&client, models_dir, remote)?;
}
Err(e) => {
qlog!("Warning: stat failed for {}: {}. Updating...", fname, e);
wlog!("Stat failed for {}: {}. Updating...", fname, e);
download_one_model(&client, models_dir, remote)?;
}
}