Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
190 changes: 113 additions & 77 deletions crates/cli/src/banner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ const BORDER_BR: char = '╯';
const BORDER_H: char = '─';
const BORDER_V: char = '│';

#[derive(Clone, Copy)]
struct DockTagSpan {
row: usize,
start: usize,
end: usize,
}

enum CellStyle {
DockTag,
Figlet,
Plain,
}

fn supports_banner() -> bool {
if !std::io::stdout().is_terminal() {
return false;
Expand Down Expand Up @@ -93,91 +106,18 @@ fn render_frame_inner(color: bool, docked: bool) -> String {
let mut out = String::with_capacity(BANNER_LINES.iter().map(|l| l.len() + 64).sum());
out.push('\n');

// Build a 2D grid: empty top rail, the 6 figlet rows, empty bottom rail. Each cell is a
// single char (we treat Unicode block chars as 1 display column wide, which is true for the
// glyphs the figlet uses).
let mut grid: Vec<Vec<char>> = Vec::with_capacity(TOTAL_ROWS);
let dock_tag = format!(" v{}", env!("CARGO_PKG_VERSION"));
let dock_width_needed = COL_END + dock_tag.chars().count() + 2;
let max_width = BANNER_LINES
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
.max(dock_width_needed);

// Top rail (empty).
grid.push(vec![' '; max_width]);
// 6 figlet rows, padded to max_width.
for line in BANNER_LINES {
let mut row: Vec<char> = line.chars().collect();
while row.len() < max_width {
row.push(' ');
}
grid.push(row);
}
// Bottom rail (empty).
grid.push(vec![' '; max_width]);

// Overlay the docked version tag at bottom-right: just "vX.Y.Z" in dim green. No dot — the
// version reads as a quiet label below "Flow", letting the brand mark stand on its own.
let dock_col_start = COL_END;
let dock_col_end = dock_col_start + dock_tag.chars().count();
if docked {
let dock_row = BOTTOM_RAIL;
for (i, ch) in dock_tag.chars().enumerate() {
let c = dock_col_start + i;
if dock_row < grid.len() && c < grid[dock_row].len() {
grid[dock_row][c] = ch;
}
}
}
let max_width = frame_width(&dock_tag);
let mut grid = build_grid(max_width);
let dock_tag_span = docked.then(|| overlay_dock_tag(&mut grid, &dock_tag));

// Top border row.
push_border_line(&mut out, BORDER_TL, BORDER_TR, max_width, color);

// Emit the grid with appropriate coloring per cell. Each grid row is wrapped with a
// vertical border on the left and right, painted in NVIDIA green.
for (row_idx, row) in grid.iter().enumerate() {
if color {
out.push_str(NVIDIA_GREEN);
out.push(BORDER_V);
out.push_str(RESET);
} else {
out.push(BORDER_V);
}
for (col_idx, ch) in row.iter().enumerate() {
let in_dock_tag = docked
&& row_idx == BOTTOM_RAIL
&& col_idx >= dock_col_start
&& col_idx < dock_col_end;
if in_dock_tag && *ch != ' ' {
if color {
out.push_str(DOCK_TAG);
out.push(*ch);
out.push_str(RESET);
} else {
out.push(*ch);
}
} else if is_figlet_glyph(*ch) {
if color {
out.push_str(NVIDIA_GREEN);
out.push(*ch);
out.push_str(RESET);
} else {
out.push(*ch);
}
} else {
out.push(*ch);
}
}
if color {
out.push_str(NVIDIA_GREEN);
out.push(BORDER_V);
out.push_str(RESET);
} else {
out.push(BORDER_V);
}
push_grid_row(&mut out, row_idx, row, dock_tag_span, color);
out.push('\n');
}

Expand All @@ -187,6 +127,102 @@ fn render_frame_inner(color: bool, docked: bool) -> String {
out
}

fn frame_width(dock_tag: &str) -> usize {
let dock_width_needed = COL_END + dock_tag.chars().count() + 2;
BANNER_LINES
.iter()
.map(|l| l.chars().count())
.max()
.unwrap_or(0)
.max(dock_width_needed)
}

fn build_grid(width: usize) -> Vec<Vec<char>> {
// Empty top rail, the 6 figlet rows, and an empty bottom rail. Each cell is a single char
// because the figlet's block and box glyphs render as one display column in target terminals.
let mut grid = Vec::with_capacity(TOTAL_ROWS);
grid.push(vec![' '; width]);
grid.extend(BANNER_LINES.iter().map(|line| padded_row(line, width)));
grid.push(vec![' '; width]);
grid
}

fn padded_row(line: &str, width: usize) -> Vec<char> {
let mut row: Vec<char> = line.chars().collect();
row.resize(width, ' ');
row
}

fn overlay_dock_tag(grid: &mut [Vec<char>], dock_tag: &str) -> DockTagSpan {
let span = DockTagSpan {
row: BOTTOM_RAIL,
start: COL_END,
end: COL_END + dock_tag.chars().count(),
};
for (index, ch) in dock_tag.chars().enumerate() {
grid[span.row][span.start + index] = ch;
}
span
}

fn push_grid_row(
out: &mut String,
row_idx: usize,
row: &[char],
dock_tag_span: Option<DockTagSpan>,
color: bool,
) {
push_vertical_border(out, color);
for (col_idx, ch) in row.iter().copied().enumerate() {
push_cell(
out,
ch,
cell_style(ch, row_idx, col_idx, dock_tag_span),
color,
);
}
push_vertical_border(out, color);
}

fn push_vertical_border(out: &mut String, color: bool) {
push_styled_char(out, BORDER_V, Some(NVIDIA_GREEN), color);
}

fn push_cell(out: &mut String, ch: char, style: CellStyle, color: bool) {
match style {
CellStyle::DockTag => push_styled_char(out, ch, Some(DOCK_TAG), color),
CellStyle::Figlet => push_styled_char(out, ch, Some(NVIDIA_GREEN), color),
CellStyle::Plain => out.push(ch),
}
}

fn push_styled_char(out: &mut String, ch: char, style: Option<&str>, color: bool) {
if color && let Some(style) = style {
out.push_str(style);
out.push(ch);
out.push_str(RESET);
} else {
out.push(ch);
}
}

fn cell_style(
ch: char,
row_idx: usize,
col_idx: usize,
dock_tag_span: Option<DockTagSpan>,
) -> CellStyle {
if dock_tag_span.is_some_and(|span| {
row_idx == span.row && col_idx >= span.start && col_idx < span.end && ch != ' '
}) {
CellStyle::DockTag
} else if is_figlet_glyph(ch) {
CellStyle::Figlet
} else {
CellStyle::Plain
}
}

fn push_border_line(out: &mut String, left: char, right: char, inner_width: usize, color: bool) {
if color {
out.push_str(NVIDIA_GREEN);
Expand Down
118 changes: 73 additions & 45 deletions crates/cli/src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,60 +116,88 @@ pub(crate) fn detect_installed_agents_in(path_var: Option<&std::ffi::OsStr>) ->
pub(crate) fn build_config(answers: &SetupAnswers) -> DocumentMut {
let mut doc = DocumentMut::new();

// Build the exporter table once so selecting multiple backends produces nested per-exporter
// sections, not separate legacy observability/export blocks.
let want_atif = answers.backends.contains(&ObservabilityBackend::Atif);
let want_atof = answers.backends.contains(&ObservabilityBackend::Atof);
let want_openinference = answers
.backends
.contains(&ObservabilityBackend::OpenInference)
&& answers.openinference_endpoint.is_some();
if want_atif || want_atof || want_openinference {
let mut exporters = Table::new();
if want_atif {
let mut atif = Table::new();
atif["dir"] = value("./atif");
exporters.insert("atif", Item::Table(atif));
}
if want_atof {
let mut atof = Table::new();
atof["dir"] = value("./atof");
atof["mode"] = value("append");
atof["filename_template"] = value("{session_id}.jsonl");
exporters.insert("atof", Item::Table(atof));
}
if let Some(endpoint) = answers.openinference_endpoint.as_deref() {
let mut openinference = Table::new();
openinference["endpoint"] = value(endpoint);
exporters.insert("openinference", Item::Table(openinference));
}
if let Some(exporters) = build_exporters_table(answers) {
doc["exporters"] = Item::Table(exporters);
}

if !answers.agents.is_empty() {
let mut agents_table = Table::new();
for agent in &answers.agents {
let (key, command) = match agent {
CodingAgent::ClaudeCode => ("claude", "claude"),
CodingAgent::Codex => ("codex", "codex"),
CodingAgent::Cursor => ("cursor", "cursor-agent"),
CodingAgent::Hermes => ("hermes", "hermes"),
};
let mut agent_table = Table::new();
agent_table["command"] = value(command);
if matches!(agent, CodingAgent::Hermes)
&& let Some(path) = answers.hermes_hooks_path.as_deref()
{
agent_table["hooks_path"] = value(path.display().to_string());
}
agents_table.insert(key, Item::Table(agent_table));
}
if let Some(agents_table) = build_agents_table(answers) {
doc["agents"] = Item::Table(agents_table);
}

doc
}

fn build_exporters_table(answers: &SetupAnswers) -> Option<Table> {
if !has_selected_exporter(answers) {
return None;
}

let mut exporters = Table::new();
if answers.backends.contains(&ObservabilityBackend::Atif) {
insert_atif_exporter(&mut exporters);
}
if answers.backends.contains(&ObservabilityBackend::Atof) {
insert_atof_exporter(&mut exporters);
}
if answers
.backends
.contains(&ObservabilityBackend::OpenInference)
&& let Some(endpoint) = answers.openinference_endpoint.as_deref()
{
insert_openinference_exporter(&mut exporters, endpoint);
}
Some(exporters)
}

fn has_selected_exporter(answers: &SetupAnswers) -> bool {
answers.backends.contains(&ObservabilityBackend::Atif)
|| answers.backends.contains(&ObservabilityBackend::Atof)
|| (answers
.backends
.contains(&ObservabilityBackend::OpenInference)
&& answers.openinference_endpoint.is_some())
}

fn insert_atif_exporter(exporters: &mut Table) {
let mut atif = Table::new();
atif["dir"] = value("./atif");
exporters.insert("atif", Item::Table(atif));
}

fn insert_atof_exporter(exporters: &mut Table) {
let mut atof = Table::new();
atof["dir"] = value("./atof");
atof["mode"] = value("append");
atof["filename_template"] = value("{session_id}.jsonl");
exporters.insert("atof", Item::Table(atof));
}

fn insert_openinference_exporter(exporters: &mut Table, endpoint: &str) {
let mut openinference = Table::new();
openinference["endpoint"] = value(endpoint);
exporters.insert("openinference", Item::Table(openinference));
}

fn build_agents_table(answers: &SetupAnswers) -> Option<Table> {
if answers.agents.is_empty() {
return None;
}

let mut agents_table = Table::new();
for agent in &answers.agents {
let (key, command) = agent_key_and_command(*agent);
let mut agent_table = Table::new();
agent_table["command"] = value(command);
if matches!(agent, CodingAgent::Hermes)
&& let Some(path) = answers.hermes_hooks_path.as_deref()
{
agent_table["hooks_path"] = value(path.display().to_string());
}
agents_table.insert(key, Item::Table(agent_table));
}
Some(agents_table)
}

/// Writes the setup's TOML document to the scope-appropriate path(s).
///
/// When `merge_scope` is `Some(agent)`, an existing `config.toml` at the target path is parsed
Expand Down
17 changes: 17 additions & 0 deletions crates/cli/tests/coverage/setup_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@ fn build_config_emits_exporters_section_when_openinference_selected() {
assert!(!rendered.contains("[observability]"));
}

#[test]
fn build_config_ignores_openinference_endpoint_when_backend_not_selected() {
let answers = SetupAnswers {
scope: ConfigScope::Project,
agents: vec![],
backends: vec![ObservabilityBackend::Atif],
openinference_endpoint: Some("http://localhost:6006/v1/traces".into()),
hermes_hooks_path: None,
};

let rendered = build_config(&answers).to_string();

assert!(rendered.contains("[exporters.atif]"));
assert!(!rendered.contains("[exporters.openinference]"));
assert!(!rendered.contains("http://localhost:6006/v1/traces"));
}

#[test]
fn build_config_emits_atof_write_options_when_atof_selected() {
let answers = SetupAnswers {
Expand Down
Loading