diff --git a/Cargo.lock b/Cargo.lock index abb8b9b5a..738b1edbe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -516,6 +516,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +[[package]] +name = "cli-table" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14da8d951cef7cc4f13ccc9b744d736963d57863c7e6fc33c070ea274546082c" +dependencies = [ + "termcolor", + "unicode-width 0.2.2", +] + [[package]] name = "cmake" version = "0.1.57" @@ -1381,6 +1391,7 @@ dependencies = [ "base64", "bytes", "clap", + "cli-table", "dialoguer", "dotenvy", "fabro-util", @@ -1544,6 +1555,7 @@ dependencies = [ "base64", "chrono", "clap", + "cli-table", "console 0.15.11", "daytona-api-client", "daytona-sdk", @@ -4876,6 +4888,15 @@ dependencies = [ "utf-8", ] +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "termimad" version = "0.34.1" diff --git a/Cargo.toml b/Cargo.toml index a9d9808a9..8ddf32162 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,6 +30,7 @@ jsonschema = "0.42" chrono = { version = "0.4", features = ["clock"] } bollard = "0.18" tar = "0.4" +cli-table = { version = "0.5", default-features = false } console = "0.15" dialoguer = "0.12" git2 = "0.20" diff --git a/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd b/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd index 033bd1986..9343f250a 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/bare.trycmd @@ -1,24 +1,24 @@ ```console $ fabro model -MODEL PROVIDER ALIASES CONTEXT COST SPEED -claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s -claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s -claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s -claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s -gpt-5.2 openai gpt5 1m $1.8 / $14.0 65 tok/s -gpt-5-mini openai gpt5-mini 1m $0.2 / $2.0 70 tok/s -gpt-5.2-codex openai 1m $1.8 / $14.0 100 tok/s -gpt-5.3-codex openai codex 1m $1.8 / $14.0 100 tok/s -gpt-5.3-codex-spark openai codex-spark 131k - / - 1000 tok/s -gpt-5.4 openai gpt54 1m $2.5 / $15.0 70 tok/s -gpt-5.4-pro openai gpt54-pro 1m $30.0 / $180.0 20 tok/s -gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s -gemini-3.1-pro-preview-customtools gemini gemini-customtools 1m $2.0 / $12.0 85 tok/s -gemini-3-flash-preview gemini gemini-flash 1m $0.5 / $3.0 150 tok/s -gemini-3.1-flash-lite-preview gemini gemini-flash-lite 1m $0.2 / $1.5 200 tok/s -kimi-k2.5 kimi kimi 262k $0.6 / $3.0 50 tok/s -glm-4.7 zai glm, glm4 203k $0.6 / $2.2 100 tok/s -minimax-m2.5 minimax minimax 197k $0.3 / $1.2 45 tok/s -mercury-2 inception mercury 131k $0.2 / $0.8 1000 tok/s - + MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  + claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s  + claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s  + claude-sonnet-4-6   anthropic  sonnet, claude-sonnet   200k   $3.0 / $15.0   50 tok/s  + claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s  + gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s  + gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s  + gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s  + gpt-5.4   openai   gpt54    1m   $2.5 / $15.0   70 tok/s  + gpt-5.4-pro   openai   gpt54-pro    1m  $30.0 / $180.0   20 tok/s  + gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s  + gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s  + gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s  + gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s  + kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s  + glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s  + minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s  + mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s  + ``` diff --git a/lib/crates/fabro-cli/tests/cmd/model/list-provider.trycmd b/lib/crates/fabro-cli/tests/cmd/model/list-provider.trycmd index 7e884a0bc..83d18371b 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/list-provider.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/list-provider.trycmd @@ -1,9 +1,9 @@ ```console $ fabro model list --provider anthropic -MODEL PROVIDER ALIASES CONTEXT COST SPEED -claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s -claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s -claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s -claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s - + MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  + claude-opus-4-6   anthropic  opus, claude-opus    1m  $15.0 / $75.0   25 tok/s  + claude-sonnet-4-5  anthropic      200k   $3.0 / $15.0   50 tok/s  + claude-sonnet-4-6  anthropic  sonnet, claude-sonnet   200k   $3.0 / $15.0   50 tok/s  + claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0  100 tok/s  + ``` diff --git a/lib/crates/fabro-cli/tests/cmd/model/list-query-aliases.trycmd b/lib/crates/fabro-cli/tests/cmd/model/list-query-aliases.trycmd index 2a5905dd3..6a6ca5c05 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/list-query-aliases.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/list-query-aliases.trycmd @@ -1,8 +1,8 @@ ```console $ fabro model list --query codex -MODEL PROVIDER ALIASES CONTEXT COST SPEED -gpt-5.2-codex openai 1m $1.8 / $14.0 100 tok/s -gpt-5.3-codex openai codex 1m $1.8 / $14.0 100 tok/s -gpt-5.3-codex-spark openai codex-spark 131k - / - 1000 tok/s - + MODEL   PROVIDER  ALIASES   CONTEXT   COST   SPEED  + gpt-5.2-codex   openai       1m  $1.8 / $14.0   100 tok/s  + gpt-5.3-codex   openai   codex    1m  $1.8 / $14.0   100 tok/s  + gpt-5.3-codex-spark  openai   codex-spark   131k   - / -  1000 tok/s  + ``` diff --git a/lib/crates/fabro-cli/tests/cmd/model/list-query-case-insensitive.trycmd b/lib/crates/fabro-cli/tests/cmd/model/list-query-case-insensitive.trycmd index b09ce9e71..b7b4381ee 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/list-query-case-insensitive.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/list-query-case-insensitive.trycmd @@ -1,6 +1,6 @@ ```console $ fabro model list --query OPUS -MODEL PROVIDER ALIASES CONTEXT COST SPEED -claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s - + MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  + claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s  + ``` diff --git a/lib/crates/fabro-cli/tests/cmd/model/list-query.trycmd b/lib/crates/fabro-cli/tests/cmd/model/list-query.trycmd index fd1f45fbb..e3ae96a60 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/list-query.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/list-query.trycmd @@ -1,6 +1,6 @@ ```console $ fabro model list --query opus -MODEL PROVIDER ALIASES CONTEXT COST SPEED -claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s - + MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  + claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s  + ``` diff --git a/lib/crates/fabro-cli/tests/cmd/model/list.trycmd b/lib/crates/fabro-cli/tests/cmd/model/list.trycmd index 19f79b975..1447601bf 100644 --- a/lib/crates/fabro-cli/tests/cmd/model/list.trycmd +++ b/lib/crates/fabro-cli/tests/cmd/model/list.trycmd @@ -1,24 +1,24 @@ ```console $ fabro model list -MODEL PROVIDER ALIASES CONTEXT COST SPEED -claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s -claude-sonnet-4-5 anthropic 200k $3.0 / $15.0 50 tok/s -claude-sonnet-4-6 anthropic sonnet, claude-sonnet 200k $3.0 / $15.0 50 tok/s -claude-haiku-4-5 anthropic haiku, claude-haiku 200k $0.8 / $4.0 100 tok/s -gpt-5.2 openai gpt5 1m $1.8 / $14.0 65 tok/s -gpt-5-mini openai gpt5-mini 1m $0.2 / $2.0 70 tok/s -gpt-5.2-codex openai 1m $1.8 / $14.0 100 tok/s -gpt-5.3-codex openai codex 1m $1.8 / $14.0 100 tok/s -gpt-5.3-codex-spark openai codex-spark 131k - / - 1000 tok/s -gpt-5.4 openai gpt54 1m $2.5 / $15.0 70 tok/s -gpt-5.4-pro openai gpt54-pro 1m $30.0 / $180.0 20 tok/s -gemini-3.1-pro-preview gemini gemini-pro 1m $2.0 / $12.0 85 tok/s -gemini-3.1-pro-preview-customtools gemini gemini-customtools 1m $2.0 / $12.0 85 tok/s -gemini-3-flash-preview gemini gemini-flash 1m $0.5 / $3.0 150 tok/s -gemini-3.1-flash-lite-preview gemini gemini-flash-lite 1m $0.2 / $1.5 200 tok/s -kimi-k2.5 kimi kimi 262k $0.6 / $3.0 50 tok/s -glm-4.7 zai glm, glm4 203k $0.6 / $2.2 100 tok/s -minimax-m2.5 minimax minimax 197k $0.3 / $1.2 45 tok/s -mercury-2 inception mercury 131k $0.2 / $0.8 1000 tok/s - + MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  + claude-opus-4-6   anthropic  opus, claude-opus    1m   $15.0 / $75.0   25 tok/s  + claude-sonnet-4-5   anthropic      200k   $3.0 / $15.0   50 tok/s  + claude-sonnet-4-6   anthropic  sonnet, claude-sonnet   200k   $3.0 / $15.0   50 tok/s  + claude-haiku-4-5   anthropic  haiku, claude-haiku    200k   $0.8 / $4.0   100 tok/s  + gpt-5.2   openai   gpt5    1m   $1.8 / $14.0   65 tok/s  + gpt-5-mini   openai   gpt5-mini    1m   $0.2 / $2.0   70 tok/s  + gpt-5.2-codex   openai       1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex   openai   codex    1m   $1.8 / $14.0   100 tok/s  + gpt-5.3-codex-spark   openai   codex-spark    131k   - / -  1000 tok/s  + gpt-5.4   openai   gpt54    1m   $2.5 / $15.0   70 tok/s  + gpt-5.4-pro   openai   gpt54-pro    1m  $30.0 / $180.0   20 tok/s  + gemini-3.1-pro-preview   gemini   gemini-pro    1m   $2.0 / $12.0   85 tok/s  + gemini-3.1-pro-preview-customtools  gemini   gemini-customtools    1m   $2.0 / $12.0   85 tok/s  + gemini-3-flash-preview   gemini   gemini-flash    1m   $0.5 / $3.0   150 tok/s  + gemini-3.1-flash-lite-preview   gemini   gemini-flash-lite    1m   $0.2 / $1.5   200 tok/s  + kimi-k2.5   kimi   kimi    262k   $0.6 / $3.0   50 tok/s  + glm-4.7   zai   glm, glm4    203k   $0.6 / $2.2   100 tok/s  + minimax-m2.5   minimax   minimax    197k   $0.3 / $1.2   45 tok/s  + mercury-2   inception  mercury    131k   $0.2 / $0.8  1000 tok/s  + ``` diff --git a/lib/crates/fabro-llm/Cargo.toml b/lib/crates/fabro-llm/Cargo.toml index 9d6e14afc..cd8e70bc3 100644 --- a/lib/crates/fabro-llm/Cargo.toml +++ b/lib/crates/fabro-llm/Cargo.toml @@ -27,6 +27,7 @@ reqwest.workspace = true base64.workspace = true bytes.workspace = true tokio-util.workspace = true +cli-table.workspace = true clap.workspace = true dialoguer.workspace = true tracing.workspace = true diff --git a/lib/crates/fabro-llm/src/cli.rs b/lib/crates/fabro-llm/src/cli.rs index d92a62a7d..d9afae73a 100644 --- a/lib/crates/fabro-llm/src/cli.rs +++ b/lib/crates/fabro-llm/src/cli.rs @@ -6,6 +6,8 @@ use std::time::Duration; use anyhow::{bail, Context, Result}; use clap::{Args, Subcommand}; +use cli_table::format::{Border, Justify, Separator}; +use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table}; use futures::StreamExt; use serde::Deserialize; @@ -107,30 +109,64 @@ fn format_speed(tps: Option) -> String { } } -fn print_models_table(models: &[crate::types::ModelInfo], s: &Styles) { - println!( - "{}", - s.bold_dim.apply_to(format!( - "{:<30} {:<12} {:<24} {:>10} {:>7} {:>7} {:>10}", - "MODEL", "PROVIDER", "ALIASES", "CONTEXT", "COST", "", "SPEED", - )), - ); - for model in models { - let aliases = model.aliases.join(", "); - println!( - "{} {} {} {:>10} {:>7} / {:<7} {}", - s.bold.apply_to(format!("{:<30}", model.id)), - s.dim.apply_to(format!("{:<12}", model.provider)), - s.dim.apply_to(format!("{:<24}", aliases)), - format_context_window(model.limits.context_window), - format_cost(model.costs.input_cost_per_mtok), - format_cost(model.costs.output_cost_per_mtok), - s.cyan - .apply_to(format!("{:>10}", format_speed(model.estimated_output_tps))), - ); +fn color_if(use_color: bool, color: Color) -> Option { + if use_color { + Some(color) + } else { + None } } +fn model_row(model: &crate::types::ModelInfo, use_color: bool) -> Vec { + let aliases = model.aliases.join(", "); + let cost = format!( + "{} / {}", + format_cost(model.costs.input_cost_per_mtok), + format_cost(model.costs.output_cost_per_mtok), + ); + vec![ + model.id.clone().cell().bold(use_color), + model + .provider + .clone() + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), + aliases + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), + format_context_window(model.limits.context_window) + .cell() + .justify(Justify::Right), + cost.cell().justify(Justify::Right), + format_speed(model.estimated_output_tps) + .cell() + .justify(Justify::Right) + .foreground_color(color_if(use_color, Color::Cyan)), + ] +} + +fn models_title() -> Vec { + vec![ + "MODEL".cell().bold(true), + "PROVIDER".cell().bold(true), + "ALIASES".cell().bold(true), + "CONTEXT".cell().bold(true).justify(Justify::Right), + "COST".cell().bold(true).justify(Justify::Right), + "SPEED".cell().bold(true).justify(Justify::Right), + ] +} + +fn print_models_table(models: &[crate::types::ModelInfo], s: &Styles) { + let use_color = s.use_color; + let rows: Vec> = models.iter().map(|m| model_row(m, use_color)).collect(); + let table = rows + .table() + .title(models_title()) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + let _ = print_stdout(table); +} + fn read_stdin_prompt() -> Option { let stdin = io::stdin(); if stdin.is_terminal() { @@ -812,46 +848,48 @@ async fn test_models_via_server( bail!("No models found"); } - println!( - "{}", - s.bold_dim.apply_to(format!( - "{:<30} {:<12} {:>10} {:>7} {:>7} {:>10} RESULT", - "MODEL", "PROVIDER", "CONTEXT", "COST", "", "SPEED", - )), - ); + let use_color = s.use_color; + let mut title = models_title(); + title.push("RESULT".cell().bold(true)); + let mut rows: Vec> = Vec::new(); let mut failures = 0u32; for info in &models_to_test { + eprint!("Testing {}...", info.id); let result = test_model_via_server(&server.client, &server.base_url, &info.id).await; + eprintln!(" done"); - let (status_color, status) = match result { - Ok(resp) if resp.status == "ok" => (&s.green, "ok".to_string()), + let (result_color, status) = match result { + Ok(resp) if resp.status == "ok" => (Color::Green, "ok".to_string()), Ok(resp) => { failures += 1; let msg = resp .error_message .unwrap_or_else(|| "unknown error".to_string()); - (&s.red, format!("error: {msg}")) + (Color::Red, format!("error: {msg}")) } Err(e) => { failures += 1; - (&s.red, format!("error: {e}")) + (Color::Red, format!("error: {e}")) } }; - println!( - "{} {} {:>10} {:>7} / {:<7} {} {}", - s.bold.apply_to(format!("{:<30}", info.id)), - s.dim.apply_to(format!("{:<12}", info.provider)), - format_context_window(info.limits.context_window), - format_cost(info.costs.input_cost_per_mtok), - format_cost(info.costs.output_cost_per_mtok), - s.cyan - .apply_to(format!("{:>10}", format_speed(info.estimated_output_tps))), - status_color.apply_to(&status), + let mut row = model_row(info, use_color); + row.push( + status + .cell() + .foreground_color(color_if(use_color, result_color)), ); + rows.push(row); } + let table = rows + .table() + .title(title) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + print_stdout(table)?; + if failures > 0 { bail!("{failures} model(s) failed"); } @@ -919,16 +957,14 @@ async fn test_models(provider: Option<&str>, model: Option<&str>, s: &Styles) -> bail!("No models found"); } - println!( - "{}", - s.bold_dim.apply_to(format!( - "{:<30} {:<12} {:>10} {:>7} {:>7} {:>10} RESULT", - "MODEL", "PROVIDER", "CONTEXT", "COST", "", "SPEED", - )), - ); + let use_color = s.use_color; + let mut title = models_title(); + title.push("RESULT".cell().bold(true)); + let mut rows: Vec> = Vec::new(); let mut failures = 0u32; for info in &models_to_test { + eprint!("Testing {}...", info.id); let params = GenerateParams::new(&info.id) .provider(&info.provider) .prompt("Say OK") @@ -936,32 +972,36 @@ async fn test_models(provider: Option<&str>, model: Option<&str>, s: &Styles) -> let result = tokio::time::timeout(Duration::from_secs(30), generate::generate(params)).await; + eprintln!(" done"); - let (status_color, status) = match result { - Ok(Ok(_)) => (&s.green, "ok".to_string()), + let (result_color, status) = match result { + Ok(Ok(_)) => (Color::Green, "ok".to_string()), Ok(Err(e)) => { failures += 1; - (&s.red, format!("error: {e}")) + (Color::Red, format!("error: {e}")) } Err(_) => { failures += 1; - (&s.red, "error: timeout (30s)".to_string()) + (Color::Red, "error: timeout (30s)".to_string()) } }; - println!( - "{} {} {:>10} {:>7} / {:<7} {} {}", - s.bold.apply_to(format!("{:<30}", info.id)), - s.dim.apply_to(format!("{:<12}", info.provider)), - format_context_window(info.limits.context_window), - format_cost(info.costs.input_cost_per_mtok), - format_cost(info.costs.output_cost_per_mtok), - s.cyan - .apply_to(format!("{:>10}", format_speed(info.estimated_output_tps))), - status_color.apply_to(&status), + let mut row = model_row(info, use_color); + row.push( + status + .cell() + .foreground_color(color_if(use_color, result_color)), ); + rows.push(row); } + let table = rows + .table() + .title(title) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + print_stdout(table)?; + if failures > 0 { bail!("{failures} model(s) failed"); } diff --git a/lib/crates/fabro-workflows/Cargo.toml b/lib/crates/fabro-workflows/Cargo.toml index 93f4aea64..42f804caa 100644 --- a/lib/crates/fabro-workflows/Cargo.toml +++ b/lib/crates/fabro-workflows/Cargo.toml @@ -53,6 +53,7 @@ sha2 = { workspace = true } shlex = "1" strsim = "0.11" git2.workspace = true +cli-table.workspace = true console.workspace = true indicatif.workspace = true tokio-util.workspace = true diff --git a/lib/crates/fabro-workflows/src/cli/rewind.rs b/lib/crates/fabro-workflows/src/cli/rewind.rs index 090436054..7a977c73e 100644 --- a/lib/crates/fabro-workflows/src/cli/rewind.rs +++ b/lib/crates/fabro-workflows/src/cli/rewind.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use anyhow::{bail, Context, Result}; use clap::Args; +use cli_table::format::{Border, Separator}; +use cli_table::{print_stderr, Cell, CellStruct, Color, Style, Table}; use fabro_git_storage::branchstore::{BranchStore, CommitInfo}; use fabro_git_storage::gitobj::Store; use fabro_util::terminal::Styles; @@ -280,39 +282,52 @@ pub fn print_timeline( return; } - eprintln!( - " {} {} {}", - styles.bold_dim.apply_to(format!("{:<6}", "@")), - styles.bold_dim.apply_to(format!("{:<30}", "Node")), - styles.bold_dim.apply_to("Details"), - ); + let use_color = styles.use_color; + let color_if = |color| if use_color { Some(color) } else { None }; - for entry in timeline { - let ordinal_str = format!("@{}", entry.ordinal); - let mut details = Vec::new(); - if entry.visit > 1 { - details.push(format!("visit {}, loop", entry.visit)); - } - if parallel_map.contains_key(&entry.node_name) { - details.push("parallel interior".to_string()); - } - if entry.run_commit_sha.is_none() { - details.push("no run commit".to_string()); - } + let title = vec![ + "@".cell().bold(true), + "Node".cell().bold(true), + "Details".cell().bold(true), + ]; - let detail_str = if details.is_empty() { - String::new() - } else { - format!("({})", details.join(", ")) - }; + let rows: Vec> = timeline + .iter() + .map(|entry| { + let ordinal_str = format!("@{}", entry.ordinal); + let mut details = Vec::new(); + if entry.visit > 1 { + details.push(format!("visit {}, loop", entry.visit)); + } + if parallel_map.contains_key(&entry.node_name) { + details.push("parallel interior".to_string()); + } + if entry.run_commit_sha.is_none() { + details.push("no run commit".to_string()); + } - eprintln!( - " {} {:<30} {}", - styles.cyan.apply_to(format!("{ordinal_str:<6}")), - entry.node_name, - styles.dim.apply_to(detail_str), - ); - } + let detail_str = if details.is_empty() { + String::new() + } else { + format!("({})", details.join(", ")) + }; + + vec![ + ordinal_str.cell().foreground_color(color_if(Color::Cyan)), + entry.node_name.clone().cell(), + detail_str + .cell() + .foreground_color(color_if(Color::Ansi256(8))), + ] + }) + .collect(); + + let table = rows + .table() + .title(title) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + let _ = print_stderr(table); } /// Move both refs backward to the target checkpoint. diff --git a/lib/crates/fabro-workflows/src/cli/runs.rs b/lib/crates/fabro-workflows/src/cli/runs.rs index faa7a9dac..e19d9cebe 100644 --- a/lib/crates/fabro-workflows/src/cli/runs.rs +++ b/lib/crates/fabro-workflows/src/cli/runs.rs @@ -5,6 +5,8 @@ use std::path::{Path, PathBuf}; use anyhow::{bail, Context, Result}; use chrono::{DateTime, Utc}; use clap::Args; +use cli_table::format::{Border, Justify, Separator}; +use cli_table::{print_stdout, Cell, CellStruct, Color, Style, Table}; use fabro_util::terminal::Styles; use serde::Serialize; use tracing::{debug, info, warn}; @@ -408,20 +410,44 @@ fn short_run_id(id: &str) -> &str { } } -fn style_status(status: &RunStatus, styles: &Styles) -> String { +fn color_if(use_color: bool, color: Color) -> Option { + if use_color { + Some(color) + } else { + None + } +} + +fn status_cell(status: &RunStatus, use_color: bool) -> CellStruct { let text = status.to_string(); match status { - RunStatus::Concluded(StageStatus::Success | StageStatus::PartialSuccess) => { - format!("{}", styles.bold_green.apply_to(&text)) - } - RunStatus::Concluded(StageStatus::Fail) => { - format!("{}", styles.bold_red.apply_to(&text)) - } - RunStatus::Running => format!("{}", styles.bold_cyan.apply_to(&text)), - _ => format!("{}", styles.dim.apply_to(&text)), + RunStatus::Concluded(StageStatus::Success | StageStatus::PartialSuccess) => text + .cell() + .bold(use_color) + .foreground_color(color_if(use_color, Color::Green)), + RunStatus::Concluded(StageStatus::Fail) => text + .cell() + .bold(use_color) + .foreground_color(color_if(use_color, Color::Red)), + RunStatus::Running => text + .cell() + .bold(use_color) + .foreground_color(color_if(use_color, Color::Cyan)), + _ => text + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), } } +fn abbreviate_home(path: &str) -> String { + if let Some(home) = dirs::home_dir() { + if let Ok(rel) = Path::new(path).strip_prefix(&home) { + return format!("~/{}", rel.display()); + } + } + path.to_string() +} + pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> { let base = default_runs_base(); let runs = scan_runs(&base)?; @@ -457,36 +483,55 @@ pub fn list_command(args: &RunsListArgs, styles: &Styles) -> Result<()> { let mut display_runs = filtered; display_runs.reverse(); - // Print table header - let header = format!( - "{:<17} {:<25} {:<17} {:<20} {:<10}", - "RUN ID", "WORKFLOW", "STATUS", "DIRECTORY", "DURATION" - ); - println!("{}", styles.bold.apply_to(&header)); - println!("{}", styles.dim.apply_to("-".repeat(header.len()))); + let use_color = styles.use_color; + let title = vec![ + "RUN ID".cell().bold(true), + "WORKFLOW".cell().bold(true), + "STATUS".cell().bold(true), + "DIRECTORY".cell().bold(true), + "DURATION".cell().bold(true), + ]; - for run in &display_runs { - let run_id_display = short_run_id(&run.run_id); - let duration_display = run - .duration_ms - .map(super::progress::format_duration_ms) - .unwrap_or_else(|| "-".to_string()); - let dir_display = run - .host_repo_path - .as_deref() - .and_then(|p| Path::new(p).file_name()) - .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "-".to_string()); - let status_display = style_status(&run.status, styles); - println!( - "{:<17} {:<25} {:<17} {:<20} {:<10}", - styles.dim.apply_to(&run_id_display), - run.workflow_name, - status_display, - dir_display, - duration_display, - ); - } + let rows: Vec> = + display_runs + .iter() + .map(|run| { + let run_id_display = short_run_id(&run.run_id); + let duration_display = match run.duration_ms { + Some(ms) => super::progress::format_duration_ms(ms), + None => match run.start_time_dt { + Some(start) => { + let elapsed = Utc::now().signed_duration_since(start); + super::progress::format_duration_ms( + elapsed.num_milliseconds().max(0) as u64 + ) + } + None => "-".to_string(), + }, + }; + let dir_display = run + .host_repo_path + .as_deref() + .map(abbreviate_home) + .unwrap_or_else(|| "-".to_string()); + vec![ + run_id_display + .cell() + .foreground_color(color_if(use_color, Color::Ansi256(8))), + run.workflow_name.clone().cell(), + status_cell(&run.status, use_color), + dir_display.cell(), + duration_display.cell(), + ] + }) + .collect(); + + let table = rows + .table() + .title(title) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + print_stdout(table)?; eprintln!("\n{} run(s) listed.", display_runs.len()); Ok(()) @@ -614,34 +659,48 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Pat }; let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 }; - println!( - "{:<14}{:>5}{:>11}{:>12}{:>16}", - "TYPE", "COUNT", "ACTIVE", "SIZE", "RECLAIMABLE" - ); - println!( - "{:<14}{:>5}{:>11}{:>12}{:>12} ({run_reclaim_pct}%)", - "Runs", - runs.len(), - active_count, - format_size(total_run_size), - format_size(reclaimable_run_size), - ); - println!( - "{:<14}{:>5}{:>11}{:>12}{:>12} ({log_reclaim_pct}%)", - "Logs", - log_count, - "-", - format_size(total_log_size), - format_size(total_log_size), - ); - println!( - "{:<14}{:>5}{:>11}{:>12}{:>12} (0%)", - "Databases", - db_count, - "-", - format_size(total_db_size), - format_size(0), - ); + let df_title = vec![ + "TYPE".cell().bold(true), + "COUNT".cell().bold(true).justify(Justify::Right), + "ACTIVE".cell().bold(true).justify(Justify::Right), + "SIZE".cell().bold(true).justify(Justify::Right), + "RECLAIMABLE".cell().bold(true).justify(Justify::Right), + ]; + let df_rows: Vec> = vec![ + vec![ + "Runs".cell(), + runs.len().cell().justify(Justify::Right), + active_count.cell().justify(Justify::Right), + format_size(total_run_size).cell().justify(Justify::Right), + format!("{} ({run_reclaim_pct}%)", format_size(reclaimable_run_size)) + .cell() + .justify(Justify::Right), + ], + vec![ + "Logs".cell(), + log_count.cell().justify(Justify::Right), + "-".cell().justify(Justify::Right), + format_size(total_log_size).cell().justify(Justify::Right), + format!("{} ({log_reclaim_pct}%)", format_size(total_log_size)) + .cell() + .justify(Justify::Right), + ], + vec![ + "Databases".cell(), + db_count.cell().justify(Justify::Right), + "-".cell().justify(Justify::Right), + format_size(total_db_size).cell().justify(Justify::Right), + format!("{} (0%)", format_size(0)) + .cell() + .justify(Justify::Right), + ], + ]; + let df_table = df_rows + .table() + .title(df_title) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + print_stdout(df_table)?; println!(); println!("Data directory: {}", data_dir.display()); @@ -649,46 +708,57 @@ pub fn df_from(args: &DfArgs, data_dir: &Path, runs_base: &Path, logs_base: &Pat // --- Verbose per-run breakdown --- if args.verbose { println!(); - println!( - "{:<30} {:<18} {:<10} {:>5} {:>12}", - "RUN ID", "WORKFLOW", "STATUS", "AGE", "SIZE" - ); + let verbose_title = vec![ + "RUN ID".cell().bold(true), + "WORKFLOW".cell().bold(true), + "STATUS".cell().bold(true), + "AGE".cell().bold(true).justify(Justify::Right), + "SIZE".cell().bold(true).justify(Justify::Right), + ]; let now = chrono::Utc::now(); - for detail in &run_details { - let run_id_display = short_run_id(&detail.run_id); - let workflow_display = if detail.workflow_name.len() > 16 { - format!("{}...", &detail.workflow_name[..13]) - } else { - detail.workflow_name.clone() - }; - let age = if let Some(dt) = detail.start_time_dt { - let dur = now.signed_duration_since(dt); - if dur.num_days() > 0 { - format!("{}d", dur.num_days()) - } else if dur.num_hours() > 0 { - format!("{}h", dur.num_hours()) + let verbose_rows: Vec> = run_details + .iter() + .map(|detail| { + let run_id_display = short_run_id(&detail.run_id); + let workflow_display = if detail.workflow_name.len() > 16 { + format!("{}...", &detail.workflow_name[..13]) } else { - format!("{}m", dur.num_minutes().max(1)) - } - } else { - "-".to_string() - }; - let reclaimable_marker = if !detail.status.is_running() { - " *" - } else { - "" - }; - println!( - "{:<30} {:<18} {:<10} {:>5} {:>10}{}", - run_id_display, - workflow_display, - detail.status, - age, - format_size(detail.size), - reclaimable_marker, - ); - } + detail.workflow_name.clone() + }; + let age = if let Some(dt) = detail.start_time_dt { + let dur = now.signed_duration_since(dt); + if dur.num_days() > 0 { + format!("{}d", dur.num_days()) + } else if dur.num_hours() > 0 { + format!("{}h", dur.num_hours()) + } else { + format!("{}m", dur.num_minutes().max(1)) + } + } else { + "-".to_string() + }; + let size_display = if !detail.status.is_running() { + format!("{} *", format_size(detail.size)) + } else { + format_size(detail.size) + }; + vec![ + run_id_display.cell(), + workflow_display.cell(), + detail.status.to_string().cell(), + age.cell().justify(Justify::Right), + size_display.cell().justify(Justify::Right), + ] + }) + .collect(); + + let verbose_table = verbose_rows + .table() + .title(verbose_title) + .border(Border::builder().build()) + .separator(Separator::builder().build()); + print_stdout(verbose_table)?; println!(); println!("* = reclaimable"); }