refactor(main): wire LoreRenderer init, migrate to Theme, improve UX polish

Wire the LoreRenderer singleton initialization into main.rs color mode
handling, replacing the console::style import with Theme throughout.

Key changes:

- Color initialization: LoreRenderer::init() called for all code paths
  (NO_COLOR, --color never/always/auto, unknown mode fallback) alongside
  the existing console::set_colors_enabled() calls. Both systems must
  agree since some transitive code still uses console (e.g. dialoguer).

- Tracing: Replace .with_target(false) with .event_format(CompactHumanFormat)
  for the stderr layer, producing the clean 'HH:MM:SS LEVEL  message' format.

- Error handling: handle_error() now shows machine-actionable recovery
  commands from gi_error.actions() below the hint, formatted with dim '$'
  prefix and bold command text.

- Deprecation warnings: All 'lore list', 'lore show', 'lore auth-test',
  'lore sync-status' warnings migrated to Theme::warning().

- Init wizard: All success/info/error messages migrated. Unicode check
  marks use explicit \u{2713} escapes instead of literal symbols.

- Embed command: Added progress bar with indicatif for embedding stage,
  showing position/total with steady tick. Elapsed time shown on completion.

- Generate-docs and ingest commands: Added 'Done in Xs' elapsed time and
  next-step hints (run embed after generate-docs, run generate-docs after
  ingest) for better workflow guidance.

- Sync output: Interrupt message and lock release migrated to Theme.

- Health command: Status labels and overall healthy/unhealthy styled.

- Robot-docs: Added drift command schema, updated sync flags to include
  --no-file-changes, updated who flags with new options.

- Timeline --expand-mentions -> --no-mentions flag rename wired through
  params and robot-docs.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Taylor Eernisse
2026-02-13 22:33:09 -05:00
parent dd00a2b840
commit 81f049a7fa

View File

@@ -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<dyn std::error::Error>, 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<dyn std::error::Error>, 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<dyn std::error::Error>> {
} 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<dyn std::error::Error>> {
} 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<dyn std::error::Error>> {
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<dyn Fn(usize, usize)> = 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<dyn std::e
},
"sync": {
"description": "Full sync pipeline: ingest -> 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<dyn std::e
},
"timeline": {
"description": "Chronological timeline of events matching a keyword query or entity reference",
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--expand-mentions", "-n/--limit", "--fields <list>", "--max-seeds", "--max-entities", "--max-evidence"],
"flags": ["<QUERY>", "-p/--project", "--since <duration>", "--depth <n>", "--no-mentions", "-n/--limit", "--fields <list>", "--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<dyn std::e
},
"who": {
"description": "People intelligence: experts, workload, active discussions, overlap, review patterns",
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit", "--fields <list>"],
"flags": ["<target>", "--path <path>", "--active", "--overlap <path>", "--reviews", "--since <duration>", "-p/--project", "-n/--limit", "--fields <list>", "--detail", "--no-detail", "--as-of <date>", "--explain-score", "--include-bots", "--all-history"],
"modes": {
"expert": "lore who <file-path> -- Who knows about this area? (also: --path for root files)",
"workload": "lore who <username> -- What is someone working on?",
@@ -2423,6 +2514,16 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"active_minimal": ["entity_type", "iid", "title", "participants"]
}
},
"drift": {
"description": "Detect discussion divergence from original issue intent",
"flags": ["<entity_type: issues>", "<IID>", "--threshold <0.0-1.0>", "-p/--project <path>"],
"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 <N>", "--author/-a <username>", "--note-type <type>", "--contains <text>", "--for-issue <iid>", "--for-mr <iid>", "-p/--project <path>", "--since <period>", "--until <period>", "--path <filepath>", "--resolution <any|unresolved|resolved>", "--sort <created|updated>", "--asc", "--include-system", "--note-id <id>", "--gitlab-note-id <id>", "--discussion-id <id>", "--format <table|json|jsonl|csv>", "--fields <list|minimal>", "--open"],
@@ -2511,7 +2612,7 @@ fn handle_robot_docs(robot_mode: bool, brief: bool) -> Result<(), Box<dyn std::e
"temporal_intelligence": [
"lore --robot sync",
"lore --robot timeline '<keyword>' --since 30d",
"lore --robot timeline '<keyword>' --depth 2 --expand-mentions"
"lore --robot timeline '<keyword>' --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);
}
}