use colored::*;
use crate::observability;
use crate::sanitize;
const MACHINE_LOG_ENV: &str = "LOOPRS_MACHINE_LOG";
pub fn init_logging() {
let mut builder = env_logger::Builder::from_default_env();
if std::env::var("RUST_LOG").is_err() {
builder.filter_level(log::LevelFilter::Warn);
}
let _ = builder.try_init();
let root = observability::observability_root();
if std::fs::create_dir_all(&root).is_ok() {
let _ = observability::append_named_jsonl(
"runtime",
&serde_json::json!({
"kind": "logging_init",
"observability_root": root.display().to_string(),
}),
);
}
}
fn machine_log_enabled() -> bool {
matches!(
std::env::var(MACHINE_LOG_ENV)
.ok()
.as_deref()
.map(str::to_ascii_lowercase)
.as_deref(),
Some("1") | Some("true")
)
}
fn emit_machine_event(kind: &str, data: serde_json::Value) {
if !machine_log_enabled() {
return;
}
let event = serde_json::json!({
"kind": kind,
"data": data,
});
if let Ok(line) = serde_json::to_string(&event) {
eprintln!("{line}");
let _ = observability::append_named_jsonl("ui_events", &event);
}
}
pub fn info(msg: impl AsRef<str>) {
let raw = msg.as_ref();
println!("{}", sanitize::sanitize_preview_for_console(raw));
emit_machine_event("info", serde_json::json!({ "message": raw }));
}
pub fn info_full(msg: impl AsRef<str>) {
println!("{}", sanitize::sanitize_for_console(msg.as_ref()));
}
pub fn warn(msg: impl AsRef<str>) {
let raw = msg.as_ref();
eprintln!("{}", sanitize::sanitize_preview_for_console(raw));
emit_machine_event("warn", serde_json::json!({ "message": raw }));
}
pub fn error(msg: impl AsRef<str>) {
let raw = msg.as_ref();
eprintln!("{}", sanitize::sanitize_preview_for_console(raw));
emit_machine_event("error", serde_json::json!({ "message": raw }));
}
pub fn error_full(msg: impl AsRef<str>) {
eprintln!("{}", sanitize::sanitize_for_console(msg.as_ref()));
}
pub fn statusline_prompt(
provider: &str,
model: &str,
fs_mode: &str,
cwd_basename: &str,
turn: usize,
) -> String {
format!(
"{}/{} {} {} {} {} [{}] {} ",
provider.cyan().bold(),
model.cyan(),
"│".dimmed(),
fs_mode.yellow(),
"│".dimmed(),
cwd_basename.dimmed(),
turn.to_string().dimmed(),
"❯".purple().bold(),
)
}
pub fn statusline_context(
provider: &str,
model: &str,
fs_mode: &str,
cwd: &str,
turn: usize,
) -> String {
format!(
"<session_context provider=\"{provider}\" model=\"{model}\" fs_mode=\"{fs_mode}\" cwd=\"{cwd}\" turn=\"{turn}\" />\n"
)
}
pub fn statusline_prompt_statusline(
cwd_basename: &str,
git: &crate::git_info::GitInfo,
model: &str,
ctx_tokens: u32,
ctx_max: u32,
session_cost: f64,
) -> String {
let git_seg = if let Some(ref branch) = git.branch {
let ahead = if git.ahead > 0 {
format!(" +{}", git.ahead)
} else {
String::new()
};
format!("git {branch}{ahead}")
} else {
String::new()
};
let chg_seg = {
let mut parts = Vec::new();
if git.modified > 0 {
parts.push(format!("{}M", git.modified));
}
if git.untracked > 0 {
parts.push(format!("{}?", git.untracked));
}
if parts.is_empty() {
String::new()
} else {
format!("chg {}", parts.join(" "))
}
};
let mdl_seg = format!("mdl {}", shorten_model(model));
let ctx_seg = if ctx_max > 0 {
let pct = (ctx_tokens as f64 / ctx_max as f64).min(1.0);
let filled = (pct * 15.0).round() as usize;
let bar: String = (0..15)
.map(|i| if i < filled { '█' } else { '░' })
.collect();
let pct_int = (pct * 100.0).round() as u32;
let k_used = ctx_tokens / 1000;
let k_max = ctx_max / 1000;
format!("ctx [{bar}] {pct_int}% {k_used}K/{k_max}K")
} else {
String::new()
};
let cost_seg = if session_cost > 0.0 {
format!("$ {session_cost:.2}")
} else {
String::new()
};
let segments: Vec<String> = [
Some(format!("dir {cwd_basename}")),
if git_seg.is_empty() {
None
} else {
Some(git_seg)
},
if chg_seg.is_empty() {
None
} else {
Some(chg_seg)
},
Some(mdl_seg),
if ctx_seg.is_empty() {
None
} else {
Some(ctx_seg)
},
if cost_seg.is_empty() {
None
} else {
Some(cost_seg)
},
]
.into_iter()
.flatten()
.collect();
let line1 = format!(" {}", segments.join(" | "));
format!("{}\n {} ", line1.dimmed(), "❯".purple().bold())
}
fn shorten_model(model: &str) -> String {
let m = model.to_lowercase();
if m.contains("claude") {
let family = if m.contains("opus") {
"Opus"
} else if m.contains("haiku") {
"Haiku"
} else if m.contains("sonnet") {
"Sonnet"
} else {
"Claude"
};
let version = model
.split('-')
.rev()
.take(2)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect::<Vec<_>>()
.join(".");
format!("{family} {version}")
} else if m.starts_with("gemini") {
model
.split('-')
.map(|s| {
let mut c = s.chars();
match c.next() {
None => String::new(),
Some(f) => f.to_uppercase().to_string() + c.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
} else {
model.to_string()
}
}
pub fn header(provider: &str, model: &str, cwd: &str) {
let p = sanitize::sanitize_preview_for_console(provider);
let m = sanitize::sanitize_preview_for_console(model);
let d = sanitize::sanitize_preview_for_console(cwd);
println!(
"{} {} | {} | {}",
">>".bold(),
"looprs".bold(),
format!("{p}/{m}").cyan(),
d.dimmed()
);
emit_machine_event(
"header",
serde_json::json!({
"provider": provider,
"model": model,
"cwd": cwd,
}),
);
}
pub fn assistant_text(text: &str) {
let safe = sanitize::sanitize_preview_for_console(text);
println!("\n{} {}", "●".blue().bold(), safe.blue());
emit_machine_event("assistant_text", serde_json::json!({ "text": text }));
}
pub fn tool_call(tool_name: &str, input_preview: &str) {
let safe_name = sanitize::sanitize_preview_for_console(tool_name);
let safe_preview = sanitize::sanitize_preview_for_console(input_preview);
println!(
"\n{} {}({})",
"⚙".yellow().bold(),
safe_name.yellow().bold(),
safe_preview.dimmed()
);
emit_machine_event(
"tool_call",
serde_json::json!({
"tool": tool_name,
"preview": input_preview,
}),
);
}
pub fn tool_ok() {
println!(" {} {}", "└─".green(), "OK".green());
emit_machine_event("tool_ok", serde_json::json!({}));
}
pub fn tool_err(err_msg: &str) {
let safe = sanitize::sanitize_preview_for_console(err_msg);
println!(" {} {}", "└─".red(), safe.red());
emit_machine_event("tool_err", serde_json::json!({ "error": err_msg }));
}
pub fn section_title(title: &str) {
let safe = sanitize::sanitize_preview_for_console(title);
println!("\n{}", safe.dimmed());
}
pub fn kv_preview(key: &str, value_preview: &str) {
let k = sanitize::sanitize_preview_for_console(key);
let v = sanitize::sanitize_preview_for_console(value_preview);
println!(" {} {}", k.cyan(), v.dimmed());
}
pub fn running_command(command: &str) {
let safe = sanitize::sanitize_preview_for_console(command);
println!("{} Running: {}", "●".dimmed(), safe.dimmed());
emit_machine_event("running_command", serde_json::json!({ "command": command }));
}
pub fn output_preview(text: &str) {
let safe = sanitize::sanitize_preview_for_console(text);
println!("{safe}");
}
pub fn output_preview_colored(text: &str) -> String {
let truncated = sanitize::truncate_preview(text);
println!("{truncated}");
sanitize::strip_ansi(&truncated)
}
pub fn goodbye() {
println!("\n{}", "Goodbye!".dimmed());
emit_machine_event("goodbye", serde_json::json!({}));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn machine_log_disabled_by_default() {
unsafe {
std::env::remove_var(MACHINE_LOG_ENV);
}
assert!(!machine_log_enabled());
}
#[test]
fn machine_log_enabled_with_true_like_values() {
for v in &["1", "true", "True", "TRUE"] {
unsafe {
std::env::set_var(MACHINE_LOG_ENV, v);
}
assert!(machine_log_enabled(), "value {v} should enable machine log");
}
}
}