Adopt cli-table for ANSI-aware table rendering and fix fabro ps bugs

Migrate all 7 CLI tables to cli-table, which measures column widths
correctly in the presence of ANSI escape codes, fixing misaligned
columns in `fabro ps`. Also fix DIRECTORY to show ~/relative paths
instead of just the last component, and compute elapsed duration for
running jobs instead of showing "-".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-15 16:07:05 -04:00
parent a2ad0e6645
commit fcd229f3e9
13 changed files with 404 additions and 255 deletions

21
Cargo.lock generated
View file

@ -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"

View file

@ -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"

View file

@ -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 

```

View file

@ -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 

```

View file

@ -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 

```

View file

@ -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 

```

View file

@ -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 

```

View file

@ -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 

```

View file

@ -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

View file

@ -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<f64>) -> 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<Color> {
if use_color {
Some(color)
} else {
None
}
}
fn model_row(model: &crate::types::ModelInfo, use_color: bool) -> Vec<CellStruct> {
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<CellStruct> {
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<Vec<CellStruct>> = 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<String> {
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<CellStruct>> = 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<CellStruct>> = 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");
}

View file

@ -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

View file

@ -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<Vec<CellStruct>> = 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.

View file

@ -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<Color> {
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<Vec<CellStruct>> =
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<CellStruct>> = 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<Vec<CellStruct>> = 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");
}