diff --git a/src/main.rs b/src/main.rs index c4c26be..42ba478 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,4 @@ use clap::Parser; -use console::style; use dialoguer::{Confirm, Input}; use serde::Serialize; use strsim::jaro_winkler; @@ -26,6 +25,7 @@ use lore::cli::commands::{ run_list_issues, run_list_mrs, run_search, run_show_issue, run_show_mr, run_stats, run_sync, run_sync_status, run_timeline, run_who, }; +use lore::cli::render::{ColorMode, LoreRenderer, Theme}; use lore::cli::robot::{RobotMeta, strip_schemas}; use lore::cli::{ Cli, Commands, CountArgs, EmbedArgs, GenerateDocsArgs, IngestArgs, IssuesArgs, MrsArgs, @@ -116,7 +116,7 @@ async fn main() { } } else { let stderr_layer = tracing_subscriber::fmt::layer() - .with_target(false) + .event_format(logging::CompactHumanFormat) .with_writer(lore::cli::progress::SuspendingWriter) .with_filter(stderr_filter); @@ -146,13 +146,23 @@ async fn main() { // I1: Respect NO_COLOR convention (https://no-color.org/) if std::env::var("NO_COLOR").is_ok_and(|v| !v.is_empty()) { + LoreRenderer::init(ColorMode::Never); console::set_colors_enabled(false); } else { match cli.color.as_str() { - "never" => console::set_colors_enabled(false), - "always" => console::set_colors_enabled(true), - "auto" => {} + "never" => { + LoreRenderer::init(ColorMode::Never); + console::set_colors_enabled(false); + } + "always" => { + LoreRenderer::init(ColorMode::Always); + console::set_colors_enabled(true); + } + "auto" => { + LoreRenderer::init(ColorMode::Auto); + } other => { + LoreRenderer::init(ColorMode::Auto); eprintln!("Warning: unknown color mode '{}', using auto", other); } } @@ -277,8 +287,9 @@ async fn main() { } else { eprintln!( "{}", - style("warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'") - .yellow() + Theme::warning().render( + "warning: 'lore list' is deprecated, use 'lore issues' or 'lore mrs'" + ) ); } handle_list_compat( @@ -318,11 +329,10 @@ async fn main() { } else { eprintln!( "{}", - style(format!( + Theme::warning().render(&format!( "warning: 'lore show' is deprecated, use 'lore {}s {}'", entity, iid )) - .yellow() ); } handle_show_compat( @@ -342,7 +352,8 @@ async fn main() { } else { eprintln!( "{}", - style("warning: 'lore auth-test' is deprecated, use 'lore auth'").yellow() + Theme::warning() + .render("warning: 'lore auth-test' is deprecated, use 'lore auth'") ); } handle_auth_test(cli.config.as_deref(), robot_mode).await @@ -355,7 +366,8 @@ async fn main() { } else { eprintln!( "{}", - style("warning: 'lore sync-status' is deprecated, use 'lore status'").yellow() + Theme::warning() + .render("warning: 'lore sync-status' is deprecated, use 'lore status'") ); } handle_sync_status_cmd(cli.config.as_deref(), robot_mode).await @@ -397,9 +409,20 @@ fn handle_error(e: Box, robot_mode: bool) -> ! { ); std::process::exit(gi_error.exit_code()); } else { - eprintln!("{} {}", style("Error:").red(), gi_error); + eprintln!("{} {}", Theme::error().render("Error:"), gi_error); if let Some(suggestion) = gi_error.suggestion() { - eprintln!("{} {}", style("Hint:").yellow(), suggestion); + eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion); + } + let actions = gi_error.actions(); + if !actions.is_empty() { + eprintln!(); + for action in &actions { + eprintln!( + " {} {}", + Theme::dim().render("$"), + Theme::bold().render(action) + ); + } } std::process::exit(gi_error.exit_code()); } @@ -420,7 +443,7 @@ fn handle_error(e: Box, robot_mode: bool) -> ! { }) ); } else { - eprintln!("{} {}", style("Error:").red(), e); + eprintln!("{} {}", Theme::error().render("Error:"), e); } std::process::exit(1); } @@ -459,7 +482,7 @@ fn emit_correction_warnings(result: &CorrectionResult, robot_mode: bool) { for c in &result.corrections { eprintln!( "{} {}", - style("Auto-corrected:").yellow(), + Theme::warning().render("Auto-corrected:"), autocorrect::format_teaching_note(c) ); } @@ -984,7 +1007,7 @@ async fn handle_ingest( if !robot_mode && !quiet { println!( "{}", - style("Ingesting all content (issues + merge requests)...").blue() + Theme::info().render("Ingesting all content (issues + merge requests)...") ); println!(); } @@ -1027,7 +1050,7 @@ async fn handle_ingest( if !robot_mode { eprintln!( "{}", - style("Interrupted by Ctrl+C. Partial data has been saved.").yellow() + Theme::warning().render("Interrupted by Ctrl+C. Partial data has been saved.") ); } Ok(()) @@ -1037,6 +1060,12 @@ async fn handle_ingest( let total_items: usize = stages.iter().map(|s| s.items_processed).sum(); let total_errors: usize = stages.iter().map(|s| s.errors).sum(); let _ = recorder.succeed(&recorder_conn, &stages, total_items, total_errors); + if !robot_mode && !quiet { + eprintln!( + "{}", + Theme::dim().render("Hint: Run 'lore generate-docs' to update searchable documents, then 'lore embed' for vectors.") + ); + } Ok(()) } Err(e) => { @@ -1311,11 +1340,10 @@ async fn handle_init( if non_interactive { eprintln!( "{}", - style(format!( + Theme::error().render(&format!( "Config file exists at {}. Use --force to overwrite.", config_path.display() )) - .red() ); std::process::exit(2); } @@ -1329,7 +1357,7 @@ async fn handle_init( .interact()?; if !confirm { - println!("{}", style("Cancelled.").yellow()); + println!("{}", Theme::warning().render("Cancelled.")); std::process::exit(2); } confirmed_overwrite = true; @@ -1408,7 +1436,7 @@ async fn handle_init( None }; - println!("{}", style("\nValidating configuration...").blue()); + println!("{}", Theme::info().render("Validating configuration...")); let result = run_init( InitInputs { @@ -1427,35 +1455,43 @@ async fn handle_init( println!( "{}", - style(format!( - "\n✓ Authenticated as @{} ({})", + Theme::success().render(&format!( + "\n\u{2713} Authenticated as @{} ({})", result.user.username, result.user.name )) - .green() ); for project in &result.projects { println!( "{}", - style(format!("✓ {} ({})", project.path, project.name)).green() + Theme::success().render(&format!("\u{2713} {} ({})", project.path, project.name)) ); } if let Some(ref dp) = result.default_project { - println!("{}", style(format!("✓ Default project: {dp}")).green()); + println!( + "{}", + Theme::success().render(&format!("\u{2713} Default project: {dp}")) + ); } println!( "{}", - style(format!("\n✓ Config written to {}", result.config_path)).green() + Theme::success().render(&format!( + "\n\u{2713} Config written to {}", + result.config_path + )) ); println!( "{}", - style(format!("✓ Database initialized at {}", result.data_dir)).green() + Theme::success().render(&format!( + "\u{2713} Database initialized at {}", + result.data_dir + )) ); println!( "{}", - style("\nSetup complete! Run 'lore doctor' to verify.").blue() + Theme::info().render("\nSetup complete! Run 'lore doctor' to verify.") ); Ok(()) @@ -1518,9 +1554,9 @@ async fn handle_auth_test( }) ); } else { - eprintln!("{} {}", style("Error:").red(), e); + eprintln!("{} {}", Theme::error().render("Error:"), e); if let Some(suggestion) = e.suggestion() { - eprintln!("{} {}", style("Hint:").yellow(), suggestion); + eprintln!("{} {}", Theme::warning().render("Hint:"), suggestion); } } std::process::exit(e.exit_code()); @@ -1647,7 +1683,7 @@ fn handle_backup(robot_mode: bool) -> Result<(), Box> { } else { eprintln!( "{} The 'backup' command is not yet implemented.", - style("Error:").red() + Theme::error().render("Error:") ); } std::process::exit(1); @@ -1669,7 +1705,7 @@ fn handle_reset(robot_mode: bool) -> Result<(), Box> { } else { eprintln!( "{} The 'reset' command is not yet implemented.", - style("Error:").red() + Theme::error().render("Error:") ); } std::process::exit(1); @@ -1728,11 +1764,11 @@ async fn handle_migrate( } else { eprintln!( "{}", - style(format!("Database not found at {}", db_path.display())).red() + Theme::error().render(&format!("Database not found at {}", db_path.display())) ); eprintln!( "{}", - style("Run 'lore init' first to create the database.").yellow() + Theme::warning().render("Run 'lore init' first to create the database.") ); } std::process::exit(10); @@ -1744,7 +1780,7 @@ async fn handle_migrate( if !robot_mode { println!( "{}", - style(format!("Current schema version: {}", before_version)).blue() + Theme::info().render(&format!("Current schema version: {}", before_version)) ); } @@ -1768,14 +1804,16 @@ async fn handle_migrate( } else if after_version > before_version { println!( "{}", - style(format!( + Theme::success().render(&format!( "Migrations applied: {} -> {}", before_version, after_version )) - .green() ); } else { - println!("{}", style("Database is already up to date.").green()); + println!( + "{}", + Theme::success().render("Database is already up to date.") + ); } Ok(()) @@ -1813,7 +1851,7 @@ async fn handle_timeline( .map(String::from), since: args.since, depth: args.depth, - expand_mentions: args.expand_mentions, + no_mentions: args.no_mentions, limit: args.limit, max_seeds: args.max_seeds, max_entities: args.max_entities, @@ -1828,7 +1866,7 @@ async fn handle_timeline( &result, result.total_events_before_limit, params.depth, - params.expand_mentions, + !params.no_mentions, args.fields.as_deref(), ); } else { @@ -1900,10 +1938,25 @@ async fn handle_generate_docs( let project = config.effective_project(args.project.as_deref()); let result = run_generate_docs(&config, args.full, project, None)?; + let elapsed = start.elapsed(); if robot_mode { - print_generate_docs_json(&result, start.elapsed().as_millis() as u64); + print_generate_docs_json(&result, elapsed.as_millis() as u64); } else { print_generate_docs(&result); + if elapsed.as_secs() >= 1 { + eprintln!( + "{}", + Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64())) + ); + } + if result.regenerated > 0 { + eprintln!( + "{}", + Theme::dim().render( + "Hint: Run 'lore embed' to update vector embeddings for changed documents." + ) + ); + } } Ok(()) } @@ -1913,6 +1966,10 @@ async fn handle_embed( args: EmbedArgs, robot_mode: bool, ) -> Result<(), Box> { + use indicatif::{ProgressBar, ProgressStyle}; + use std::sync::Arc; + use std::sync::atomic::{AtomicBool, Ordering}; + let start = std::time::Instant::now(); let config = Config::load(config_override)?; let full = args.full && !args.no_full; @@ -1928,11 +1985,45 @@ async fn handle_embed( std::process::exit(130); }); - let result = run_embed(&config, full, retry_failed, None, &signal).await?; + let embed_bar = if robot_mode { + ProgressBar::hidden() + } else { + let b = lore::cli::progress::multi().add(ProgressBar::new(0)); + b.set_style( + ProgressStyle::default_bar() + .template(" {spinner:.blue} Generating embeddings [{bar:30.cyan/dim}] {pos}/{len}") + .unwrap() + .progress_chars("=> "), + ); + b + }; + let bar_clone = embed_bar.clone(); + let tick_started = Arc::new(AtomicBool::new(false)); + let tick_clone = Arc::clone(&tick_started); + let progress_cb: Box = Box::new(move |processed, total| { + if total > 0 { + if !tick_clone.swap(true, Ordering::Relaxed) { + bar_clone.enable_steady_tick(std::time::Duration::from_millis(100)); + } + bar_clone.set_length(total as u64); + bar_clone.set_position(processed as u64); + } + }); + + let result = run_embed(&config, full, retry_failed, Some(progress_cb), &signal).await?; + embed_bar.finish_and_clear(); + + let elapsed = start.elapsed(); if robot_mode { - print_embed_json(&result, start.elapsed().as_millis() as u64); + print_embed_json(&result, elapsed.as_millis() as u64); } else { print_embed(&result); + if elapsed.as_secs() >= 1 { + eprintln!( + "{}", + Theme::dim().render(&format!(" Done in {:.1}s", elapsed.as_secs_f64())) + ); + } } Ok(()) } @@ -1962,7 +2053,7 @@ async fn handle_sync_cmd( dry_run, }; - // For dry_run, skip recording and just show the preview + // For dry run, skip recording and just show the preview if dry_run { let signal = ShutdownSignal::new(); run_sync(&config, options, None, &signal).await?; @@ -2003,13 +2094,13 @@ async fn handle_sync_cmd( eprintln!(); eprintln!( "{}", - console::style("Interrupted by Ctrl+C. Partial results:").yellow() + Theme::warning().render("Interrupted by Ctrl+C. Partial results:") ); print_sync(&result, elapsed, Some(metrics)); if released > 0 { eprintln!( "{}", - console::style(format!("Released {released} locked jobs")).dim() + Theme::dim().render(&format!("Released {released} locked jobs")) ); } } @@ -2121,9 +2212,9 @@ async fn handle_health( } else { let status = |ok: bool| { if ok { - style("pass").green() + Theme::success().render("pass") } else { - style("FAIL").red() + Theme::error().render("FAIL") } }; println!( @@ -2135,13 +2226,13 @@ async fn handle_health( println!("Schema: {} (v{})", status(schema_current), schema_version); println!(); if healthy { - println!("{}", style("Healthy").green().bold()); + println!("{}", Theme::success().bold().render("Healthy")); } else { println!( "{}", - style("Unhealthy - run 'lore doctor' for details") - .red() + Theme::error() .bold() + .render("Unhealthy - run 'lore doctor' for details") ); } } @@ -2243,7 +2334,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box generate-docs -> embed", - "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--dry-run", "--no-dry-run"], + "flags": ["--full", "--no-full", "--force", "--no-force", "--no-embed", "--no-docs", "--no-events", "--no-file-changes", "--dry-run", "--no-dry-run"], "example": "lore --robot sync", "response_schema": { "ok": "bool", @@ -2382,7 +2473,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box", "-p/--project", "--since ", "--depth ", "--expand-mentions", "-n/--limit", "--fields ", "--max-seeds", "--max-entities", "--max-evidence"], + "flags": ["", "-p/--project", "--since ", "--depth ", "--no-mentions", "-n/--limit", "--fields ", "--max-seeds", "--max-entities", "--max-evidence"], "query_syntax": { "search": "Any text -> hybrid search seeding (FTS + vector)", "entity_direct": "issue:N, i:N, mr:N, m:N -> direct entity seeding (no search, no Ollama)" @@ -2397,7 +2488,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box", "--path ", "--active", "--overlap ", "--reviews", "--since ", "-p/--project", "-n/--limit", "--fields "], + "flags": ["", "--path ", "--active", "--overlap ", "--reviews", "--since ", "-p/--project", "-n/--limit", "--fields ", "--detail", "--no-detail", "--as-of ", "--explain-score", "--include-bots", "--all-history"], "modes": { "expert": "lore who -- Who knows about this area? (also: --path for root files)", "workload": "lore who -- What is someone working on?", @@ -2423,6 +2514,16 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box", "", "--threshold <0.0-1.0>", "-p/--project "], + "example": "lore --robot drift issues 42 --threshold 0.4", + "response_schema": { + "ok": "bool", + "data": {"entity_type": "string", "iid": "int", "title": "string", "threshold": "float", "divergent_discussions": "[{discussion_id:string, similarity:float, snippet:string}]"}, + "meta": {"elapsed_ms": "int"} + } + }, "notes": { "description": "List notes from discussions with rich filtering", "flags": ["--limit/-n ", "--author/-a ", "--note-type ", "--contains ", "--for-issue ", "--for-mr ", "-p/--project ", "--since ", "--until ", "--path ", "--resolution ", "--sort ", "--asc", "--include-system", "--note-id ", "--gitlab-note-id ", "--discussion-id ", "--format ", "--fields ", "--open"], @@ -2511,7 +2612,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box' --since 30d", - "lore --robot timeline '' --depth 2 --expand-mentions" + "lore --robot timeline '' --depth 2" ], "people_intelligence": [ "lore --robot who src/path/to/feature/", @@ -2762,7 +2863,10 @@ async fn handle_list_compat( Ok(()) } _ => { - eprintln!("{}", style(format!("Unknown entity: {entity}")).red()); + eprintln!( + "{}", + Theme::error().render(&format!("Unknown entity: {entity}")) + ); std::process::exit(1); } } @@ -2799,7 +2903,10 @@ async fn handle_show_compat( Ok(()) } _ => { - eprintln!("{}", style(format!("Unknown entity: {entity}")).red()); + eprintln!( + "{}", + Theme::error().render(&format!("Unknown entity: {entity}")) + ); std::process::exit(1); } }