frigg 0.10.0

Frigg gives AI agents local, source-backed code search and navigation without sending whole repositories through every prompt.
Documentation
//! Terminal card primitives for CLI human mode: titled blocks, key-value rows, and path lines.
//!
//! Keeps utility commands and progress renderers on one visual contract without pulling clap
//! dispatch or MCP transport concerns into the shared library surface.

use std::fmt::Display;

const HUMAN_CARD_TITLE_PREFIX: &str = "╭─";
const HUMAN_CARD_ROW_PREFIX: &str = "";
const HUMAN_CARD_FOOTER_PREFIX: &str = "╰─╮ ";
const HUMAN_CARD_MAX_LABEL_WIDTH: usize = 28;
const HUMAN_BADGE_STYLE: &str = "1;97;48;5;240";
const HUMAN_BADGE_WIDTH: usize = 3;
const HUMAN_BADGE_SEPARATOR_WIDTH: usize = 1;

/// One rendered row inside a [`HumanBlock`] card: key-value pair, note, separator, or path.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HumanRow {
    Kv { label: String, value: String },
    Note(String),
    Separator,
    Path(String),
}

impl HumanRow {
    /// Key-value row with a left-aligned label column and truncated value.
    pub fn kv(label: impl Into<String>, value: impl Into<String>) -> Self {
        Self::Kv {
            label: label.into(),
            value: value.into(),
        }
    }

    /// Dim single-line note; empty notes are dropped at render time.
    pub fn note(value: impl Into<String>) -> Self {
        Self::Note(value.into())
    }

    /// Path row that rewrites `"."` into a workspace-friendly label.
    pub fn path(value: impl Into<String>) -> Self {
        Self::Path(value.into())
    }

    fn into_render_row(self) -> Option<RenderRow> {
        match self {
            Self::Kv { label, value } => Some(RenderRow::Kv { label, value }),
            Self::Note(note) if !note.trim().is_empty() => Some(RenderRow::Note(note)),
            Self::Path(path) => {
                let (label, value) = human_path_row(&path);
                Some(RenderRow::Kv { label, value })
            }
            Self::Separator | Self::Note(_) => None,
        }
    }

    fn is_separator(&self) -> bool {
        matches!(self, Self::Separator)
    }
}

/// Terminal card block with titled rows, accent styling, and optional badge column.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HumanBlock {
    title: String,
    rows: Vec<HumanRow>,
    marker: String,
    accent: String,
    rail_accent: String,
    badge: Option<String>,
    badge_mode: HumanBadgeMode,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum HumanBadgeMode {
    None,
    FirstLine,
    TextThenEmpty,
    EmptyColumn,
}

impl HumanBlock {
    /// Builds a titled card; `marker`/`accent`/`rail_accent` are ANSI style codes for color mode.
    pub fn new(
        title: impl Into<String>,
        rows: Vec<HumanRow>,
        marker: impl Into<String>,
        accent: impl Into<String>,
        rail_accent: impl Into<String>,
    ) -> Self {
        Self {
            title: title.into(),
            rows,
            marker: marker.into(),
            accent: accent.into(),
            rail_accent: rail_accent.into(),
            badge: None,
            badge_mode: HumanBadgeMode::None,
        }
    }

    /// Places a short badge on the title line only (no reserved column on body rows).
    pub fn with_badge(mut self, badge: impl Into<String>) -> Self {
        let badge = badge.into();
        if !badge.trim().is_empty() {
            self.badge = Some(badge);
            self.badge_mode = HumanBadgeMode::FirstLine;
        }
        self
    }

    /// Places a badge on the title line and keeps a blank badge column on body rows for alignment.
    pub fn with_badge_column(mut self, badge: impl Into<String>) -> Self {
        let badge = badge.into();
        if !badge.trim().is_empty() {
            self.badge = Some(badge);
            self.badge_mode = HumanBadgeMode::TextThenEmpty;
        }
        self
    }

    /// Reserves a blank badge column so multi-card stacks share the same left gutter.
    pub fn with_empty_badge_column(mut self) -> Self {
        self.badge = None;
        self.badge_mode = HumanBadgeMode::EmptyColumn;
        self
    }

    /// Renders the card within `width` display columns, applying ANSI color when enabled.
    pub fn render(self, color: bool, width: usize) -> String {
        self.render_with_min_label_width(color, width, None)
    }

    /// Like [`Self::render`], but enforces a minimum label column width across stacked cards.
    pub fn render_with_min_label_width(
        self,
        color: bool,
        width: usize,
        min_label_width: Option<usize>,
    ) -> String {
        let mut rows = self.rows;
        trim_human_separators(&mut rows);
        let rows = rows
            .into_iter()
            .filter_map(HumanRow::into_render_row)
            .collect::<Vec<_>>();
        let title_badge = match self.badge_mode {
            HumanBadgeMode::None => None,
            HumanBadgeMode::FirstLine | HumanBadgeMode::TextThenEmpty => self.badge.as_deref(),
            HumanBadgeMode::EmptyColumn => Some(""),
        };
        let badge_prefix_width = human_badge_prefix_width(title_badge);
        let title = truncate_display(&self.title, width.saturating_sub(4 + badge_prefix_width));
        let title_line = colorize(
            color,
            &self.accent,
            format!("{HUMAN_CARD_TITLE_PREFIX}{} {title}", self.marker),
        );
        let mut output = format_human_badged_line(title_badge, title_line, color);
        if rows.is_empty() {
            return output;
        }

        let row_badge = match self.badge_mode {
            HumanBadgeMode::TextThenEmpty | HumanBadgeMode::EmptyColumn => Some(""),
            HumanBadgeMode::None | HumanBadgeMode::FirstLine => None,
        };
        let row_width = width.saturating_sub(human_badge_prefix_width(row_badge));
        let label_width = human_card_label_width(&rows, row_width, min_label_width);
        let content_count = rows.len();
        for (index, row) in rows.into_iter().enumerate() {
            let row_prefix = if index + 1 == content_count {
                HUMAN_CARD_FOOTER_PREFIX
            } else {
                HUMAN_CARD_ROW_PREFIX
            };
            output.push('\n');
            let row_line = match row {
                RenderRow::Kv { label, value } => format_human_kv_row(
                    &label,
                    &value,
                    label_width,
                    row_prefix,
                    &self.rail_accent,
                    color,
                    row_width,
                ),
                RenderRow::Note(note) => {
                    format_human_kv_note(&note, row_prefix, &self.rail_accent, color, row_width)
                }
            };
            output.push_str(&format_human_badged_line(row_badge, row_line, color));
        }
        output
    }
}

/// Column width reserved before card content when a badge column is active.
pub fn human_badge_prefix_width(badge: Option<&str>) -> usize {
    if badge.is_some() {
        HUMAN_BADGE_WIDTH + HUMAN_BADGE_SEPARATOR_WIDTH
    } else {
        0
    }
}

/// Prefixes one rendered line with an optional three-character badge column.
pub fn format_human_badged_line(
    badge: Option<&str>,
    line: impl Into<String>,
    color: bool,
) -> String {
    let line = line.into();
    let Some(badge) = badge else {
        return line;
    };
    let badge = truncate_display(badge.trim(), 3);
    let badge = format!("{badge:<HUMAN_BADGE_WIDTH$}");
    let badge = if color {
        colorize(color, HUMAN_BADGE_STYLE, badge)
    } else {
        badge
    };
    format!("{badge} {line}")
}

enum RenderRow {
    Kv { label: String, value: String },
    Note(String),
}

fn trim_human_separators(rows: &mut Vec<HumanRow>) {
    while rows.first().is_some_and(HumanRow::is_separator) {
        rows.remove(0);
    }
    while rows.last().is_some_and(HumanRow::is_separator) {
        rows.pop();
    }
}

fn human_path_row(value: &str) -> (String, String) {
    match value.trim() {
        "." | "./" => ("workspace".to_owned(), "current directory (.)".to_owned()),
        "" => ("path".to_owned(), "-".to_owned()),
        _ => ("path".to_owned(), value.to_owned()),
    }
}

fn human_card_label_width(
    rows: &[RenderRow],
    width: usize,
    min_label_width: Option<usize>,
) -> usize {
    let longest = rows
        .iter()
        .filter_map(|row| match row {
            RenderRow::Kv { label, .. } => Some(display_width(label)),
            RenderRow::Note(_) => None,
        })
        .max()
        .unwrap_or(1);
    let max_for_value_column = width
        .saturating_sub(display_width(HUMAN_CARD_ROW_PREFIX) + 1 + 8)
        .max(1);
    longest
        .max(min_label_width.unwrap_or(1))
        .min(HUMAN_CARD_MAX_LABEL_WIDTH)
        .min(max_for_value_column)
        .max(1)
}

fn format_human_kv_row(
    label: &str,
    value: &str,
    label_width: usize,
    row_prefix: &str,
    accent: &str,
    color: bool,
    width: usize,
) -> String {
    let label = truncate_display(label, label_width);
    let value_prefix_width = display_width(row_prefix) + label_width + 1;
    let value = truncate_display(value, width.saturating_sub(value_prefix_width));
    format!(
        "{}{} {}",
        colorize(color, accent, row_prefix),
        colorize(color, "2", format!("{label:<label_width$}")),
        value
    )
}

fn format_human_kv_note(
    note: &str,
    row_prefix: &str,
    accent: &str,
    color: bool,
    width: usize,
) -> String {
    let budget = width.saturating_sub(display_width(row_prefix));
    format!(
        "{}{}",
        colorize(color, accent, row_prefix),
        colorize(color, "2", truncate_display(note, budget))
    )
}

fn colorize(color: bool, code: &str, value: impl Display) -> String {
    if color {
        format!("\x1b[{code}m{value}\x1b[0m")
    } else {
        value.to_string()
    }
}

fn truncate_display(value: &str, max_chars: usize) -> String {
    if max_chars == 0 {
        return String::new();
    }
    let char_count = value.chars().count();
    if char_count <= max_chars {
        return value.to_owned();
    }
    let keep = max_chars.saturating_sub(1);
    let prefix = value.chars().take(keep).collect::<String>();
    format!("{prefix}")
}

fn display_width(value: &str) -> usize {
    value.chars().count()
}