From f01d74c692f51031174cbffe883f558f6f289b33 Mon Sep 17 00:00:00 2001 From: Bryan Helmkamp Date: Mon, 30 Mar 2026 09:20:12 -0400 Subject: [PATCH] Fix cli-table ignoring NO_COLOR environment variable cli-table defaults to ColorChoice::Always, emitting ANSI escape codes regardless of NO_COLOR. Fix all 5 call sites to: 1. Pass use_color to title cell .bold() instead of hardcoding true 2. Set .color_choice(Never) when colors are disabled 3. Use .display() instead of the free print_stdout/print_stderr functions (which re-wrap with Always defaults) Affected commands: model list, model test, ps list, system df, rewind. The model test snapshots are now clean plaintext. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../fabro-cli/src/commands/run/rewind.rs | 19 ++- .../fabro-cli/src/commands/runs/list.rs | 23 ++-- .../fabro-cli/src/commands/system/df.rs | 37 ++++-- lib/crates/fabro-cli/tests/it/cmd/model.rs | 122 +++++++++--------- lib/crates/fabro-llm/src/cli.rs | 48 ++++--- 5 files changed, 146 insertions(+), 103 deletions(-) diff --git a/lib/crates/fabro-cli/src/commands/run/rewind.rs b/lib/crates/fabro-cli/src/commands/run/rewind.rs index 981b7a843..996207c5d 100644 --- a/lib/crates/fabro-cli/src/commands/run/rewind.rs +++ b/lib/crates/fabro-cli/src/commands/run/rewind.rs @@ -1,7 +1,7 @@ use anyhow::Context; use anyhow::Result; use cli_table::format::{Border, Separator}; -use cli_table::{Cell, CellStruct, Color, Style, Table, print_stderr}; +use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_config::FabroSettingsExt; use fabro_git_storage::gitobj::Store; use fabro_util::terminal::Styles; @@ -61,9 +61,9 @@ pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles) { let use_color = styles.use_color; let title = vec![ - "@".cell().bold(true), - "Node".cell().bold(true), - "Details".cell().bold(true), + "@".cell().bold(use_color), + "Node".cell().bold(use_color), + "Details".cell().bold(use_color), ]; let rows: Vec> = timeline @@ -100,10 +100,19 @@ pub(crate) fn print_timeline(timeline: &RunTimeline, styles: &Styles) { }) .collect(); + let color_choice = if use_color { + cli_table::ColorChoice::Auto + } else { + cli_table::ColorChoice::Never + }; let table = rows .table() .title(title) + .color_choice(color_choice) .border(Border::builder().build()) .separator(Separator::builder().build()); - let _ = print_stderr(table); + #[allow(clippy::print_stderr)] + if let Ok(display) = table.display() { + eprintln!("{display}"); + } } diff --git a/lib/crates/fabro-cli/src/commands/runs/list.rs b/lib/crates/fabro-cli/src/commands/runs/list.rs index 0fefc7564..006f0f29e 100644 --- a/lib/crates/fabro-cli/src/commands/runs/list.rs +++ b/lib/crates/fabro-cli/src/commands/runs/list.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::Result; use chrono::Utc; use cli_table::format::{Border, Separator}; -use cli_table::{Cell, CellStruct, Color, Style, Table, print_stdout}; +use cli_table::{Cell, CellStruct, Color, Style, Table}; use fabro_config::FabroSettingsExt; use fabro_util::terminal::Styles; @@ -68,12 +68,12 @@ pub(crate) async fn list_command( let use_color = styles.use_color; let now = Utc::now(); let title = vec![ - "RUN ID".cell().bold(true), - "WORKFLOW".cell().bold(true), - "STATUS".cell().bold(true), - "DIRECTORY".cell().bold(true), - "DURATION".cell().bold(true), - "GOAL".cell().bold(true), + "RUN ID".cell().bold(use_color), + "WORKFLOW".cell().bold(use_color), + "STATUS".cell().bold(use_color), + "DIRECTORY".cell().bold(use_color), + "DURATION".cell().bold(use_color), + "GOAL".cell().bold(use_color), ]; let rows: Vec> = display_runs @@ -112,12 +112,19 @@ pub(crate) async fn list_command( }) .collect(); + let color_choice = if use_color { + cli_table::ColorChoice::Auto + } else { + cli_table::ColorChoice::Never + }; let table = rows .table() .title(title) + .color_choice(color_choice) .border(Border::builder().build()) .separator(Separator::builder().build()); - print_stdout(table)?; + #[allow(clippy::print_stdout)] + println!("{}", table.display()?); eprintln!("\n{} run(s) listed.", display_runs.len()); Ok(()) diff --git a/lib/crates/fabro-cli/src/commands/system/df.rs b/lib/crates/fabro-cli/src/commands/system/df.rs index f1f6f0a54..202250d2d 100644 --- a/lib/crates/fabro-cli/src/commands/system/df.rs +++ b/lib/crates/fabro-cli/src/commands/system/df.rs @@ -3,7 +3,7 @@ use std::path::Path; use anyhow::Result; use chrono::{DateTime, Utc}; use cli_table::format::{Border, Justify, Separator}; -use cli_table::{Cell, CellStruct, Style, Table, print_stdout}; +use cli_table::{Cell, CellStruct, Style, Table}; use fabro_config::FabroSettingsExt; use fabro_workflows::run_lookup::{logs_base, runs_base, scan_runs_combined}; @@ -121,12 +121,19 @@ async fn df_from( }; let log_reclaim_pct = if total_log_size > 0 { 100 } else { 0 }; + let use_color = console::colors_enabled(); + let color_choice = if use_color { + cli_table::ColorChoice::Auto + } else { + cli_table::ColorChoice::Never + }; + let summary_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), + "TYPE".cell().bold(use_color), + "COUNT".cell().bold(use_color).justify(Justify::Right), + "ACTIVE".cell().bold(use_color).justify(Justify::Right), + "SIZE".cell().bold(use_color).justify(Justify::Right), + "RECLAIMABLE".cell().bold(use_color).justify(Justify::Right), ]; let summary_rows: Vec> = vec![ vec![ @@ -160,9 +167,11 @@ async fn df_from( let summary_table = summary_rows .table() .title(summary_title) + .color_choice(color_choice) .border(Border::builder().build()) .separator(Separator::builder().build()); - print_stdout(summary_table)?; + #[allow(clippy::print_stdout)] + println!("{}", summary_table.display()?); println!(); println!("Data directory: {}", data_dir.display()); @@ -173,11 +182,11 @@ async fn df_from( println!(); 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), + "RUN ID".cell().bold(use_color), + "WORKFLOW".cell().bold(use_color), + "STATUS".cell().bold(use_color), + "AGE".cell().bold(use_color).justify(Justify::Right), + "SIZE".cell().bold(use_color).justify(Justify::Right), ]; let now = Utc::now(); @@ -213,9 +222,11 @@ async fn df_from( let verbose_table = verbose_rows .table() .title(verbose_title) + .color_choice(color_choice) .border(Border::builder().build()) .separator(Separator::builder().build()); - print_stdout(verbose_table)?; + #[allow(clippy::print_stdout)] + println!("{}", verbose_table.display()?); println!(); println!("* = reclaimable"); diff --git a/lib/crates/fabro-cli/tests/it/cmd/model.rs b/lib/crates/fabro-cli/tests/it/cmd/model.rs index 98ae48c4f..ec823e330 100644 --- a/lib/crates/fabro-cli/tests/it/cmd/model.rs +++ b/lib/crates/fabro-cli/tests/it/cmd/model.rs @@ -36,28 +36,28 @@ fn bare() { success: true exit_code: 0 ----- stdout ----- -  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, gpt-54    1m   $2.5 / $15.0   70 tok/s  -  gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s  -  gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 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  - ----- stderr ----- + 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, gpt-54 1m $2.5 / $15.0 70 tok/s + gpt-5.4-pro openai gpt54-pro, gpt-54-pro 1m $30.0 / $180.0 20 tok/s + gpt-5.4-mini openai gpt54-mini, gpt-54-mini 400k $0.8 / $4.5 140 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 + ----- stderr ----- "); } @@ -70,28 +70,28 @@ fn list() { success: true exit_code: 0 ----- stdout ----- -  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, gpt-54    1m   $2.5 / $15.0   70 tok/s  -  gpt-5.4-pro   openai   gpt54-pro, gpt-54-pro    1m  $30.0 / $180.0   20 tok/s  -  gpt-5.4-mini   openai   gpt54-mini, gpt-54-mini   400k   $0.8 / $4.5   140 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  - ----- stderr ----- + 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, gpt-54 1m $2.5 / $15.0 70 tok/s + gpt-5.4-pro openai gpt54-pro, gpt-54-pro 1m $30.0 / $180.0 20 tok/s + gpt-5.4-mini openai gpt54-mini, gpt-54-mini 400k $0.8 / $4.5 140 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 + ----- stderr ----- "); } @@ -104,12 +104,12 @@ fn list_provider() { success: true exit_code: 0 ----- stdout ----- -  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  - ----- stderr ----- + 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 + ----- stderr ----- "); } @@ -122,9 +122,9 @@ fn list_query() { success: true exit_code: 0 ----- stdout ----- -  MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  -  claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s  - ----- stderr ----- + MODEL PROVIDER ALIASES CONTEXT COST SPEED + claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s + ----- stderr ----- "); } @@ -137,11 +137,11 @@ fn list_query_aliases() { success: true exit_code: 0 ----- stdout ----- -  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  - ----- stderr ----- + 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 + ----- stderr ----- "); } @@ -154,8 +154,8 @@ fn list_query_case_insensitive() { success: true exit_code: 0 ----- stdout ----- -  MODEL   PROVIDER   ALIASES   CONTEXT   COST   SPEED  -  claude-opus-4-6  anthropic  opus, claude-opus   1m  $15.0 / $75.0  25 tok/s  - ----- stderr ----- + MODEL PROVIDER ALIASES CONTEXT COST SPEED + claude-opus-4-6 anthropic opus, claude-opus 1m $15.0 / $75.0 25 tok/s + ----- stderr ----- "); } diff --git a/lib/crates/fabro-llm/src/cli.rs b/lib/crates/fabro-llm/src/cli.rs index 82bad65f7..3d75206ff 100644 --- a/lib/crates/fabro-llm/src/cli.rs +++ b/lib/crates/fabro-llm/src/cli.rs @@ -7,7 +7,7 @@ use std::time::Duration; use anyhow::{Context, Result, bail}; use clap::{Args, Subcommand}; use cli_table::format::{Border, Justify, Separator}; -use cli_table::{Cell, CellStruct, Color, Style, Table, print_stdout}; +use cli_table::{Cell, CellStruct, Color, Style, Table}; use futures::{StreamExt, stream}; use serde::Deserialize; use tokio::task; @@ -122,6 +122,14 @@ fn color_if(use_color: bool, color: Color) -> Option { if use_color { Some(color) } else { None } } +fn color_choice(use_color: bool) -> cli_table::ColorChoice { + if use_color { + cli_table::ColorChoice::Auto + } else { + cli_table::ColorChoice::Never + } +} + fn model_row(model: &Model, use_color: bool) -> Vec { let aliases = model.aliases.join(", "); let cost = format!( @@ -149,14 +157,14 @@ fn model_row(model: &Model, use_color: bool) -> Vec { ] } -fn models_title() -> Vec { +fn models_title(use_color: bool) -> 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), + "MODEL".cell().bold(use_color), + "PROVIDER".cell().bold(use_color), + "ALIASES".cell().bold(use_color), + "CONTEXT".cell().bold(use_color).justify(Justify::Right), + "COST".cell().bold(use_color).justify(Justify::Right), + "SPEED".cell().bold(use_color).justify(Justify::Right), ] } @@ -166,10 +174,14 @@ fn print_models_table(models: &[Model], s: &Styles) { let rows: Vec> = models.iter().map(|m| model_row(m, use_color)).collect(); let table = rows .table() - .title(models_title()) + .title(models_title(use_color)) + .color_choice(color_choice(use_color)) .border(Border::builder().build()) .separator(Separator::builder().build()); - let _ = print_stdout(table); + #[allow(clippy::print_stdout)] + { + println!("{}", table.display().unwrap()); + } } fn read_stdin_prompt() -> Option { @@ -948,8 +960,8 @@ async fn test_models_via_server( } let use_color = s.use_color; - let mut title = models_title(); - title.push("RESULT".cell().bold(true)); + let mut title = models_title(use_color); + title.push("RESULT".cell().bold(use_color)); let mut rows: Vec> = Vec::new(); let mut failures = 0u32; @@ -985,9 +997,11 @@ async fn test_models_via_server( let table = rows .table() .title(title) + .color_choice(color_choice(use_color)) .border(Border::builder().build()) .separator(Separator::builder().build()); - print_stdout(table)?; + #[allow(clippy::print_stdout)] + println!("{}", table.display()?); if failures > 0 { bail!("{failures} model(s) failed"); @@ -1134,8 +1148,8 @@ async fn test_models( sorted_results.sort_by_key(|(idx, _, _)| *idx); let use_color = s.use_color; - let mut title = models_title(); - title.push("RESULT".cell().bold(true)); + let mut title = models_title(use_color); + title.push("RESULT".cell().bold(use_color)); let mut rows: Vec> = Vec::new(); let mut failures = 0u32; @@ -1155,9 +1169,11 @@ async fn test_models( let table = rows .table() .title(title) + .color_choice(color_choice(use_color)) .border(Border::builder().build()) .separator(Separator::builder().build()); - print_stdout(table)?; + #[allow(clippy::print_stdout)] + println!("{}", table.display()?); if failures > 0 { bail!("{failures} model(s) failed");