clipmem 0.4.0

macOS clipboard memory backed by SQLite and searchable from agent runtimes
Documentation
use super::*;

pub(in crate::cli) fn render_list_human(envelope: &ListEnvelope) -> String {
    let theme = HumanTheme::detect();
    let title = match envelope.command {
        "search" => "clipmem Search",
        "recent" => "clipmem Recent",
        "timeline" => "clipmem Timeline",
        other => return render_unknown_list_human(other, envelope),
    };
    let mut out = header(&theme, title);
    push_meta(&mut out, envelope);

    if envelope.results.is_empty() {
        out.push_str("No results.\n");
        return out;
    }

    if envelope.command == "timeline" {
        push_timeline_table(&mut out, &theme, &envelope.results);
    } else {
        push_snapshot_table(&mut out, &theme, &envelope.results);
    }

    out
}

pub(in crate::cli) fn render_recall_human(envelope: &RecallEnvelope) -> String {
    let theme = HumanTheme::detect();
    let best = &envelope.best_candidate;
    let mut out = header(&theme, "clipmem Recall");
    let _ = writeln!(
        out,
        "Query: {}  Filters: {}",
        envelope.query.as_deref().unwrap_or("(recent clipboard)"),
        render_filter_summary(&envelope.applied_filters)
    );
    out.push('\n');

    let _ = writeln!(out, "{}", theme.section("Best Match"));
    out.push_str(&separator(60, false));
    out.push('\n');
    if let Some(quoted_text) = &envelope.quoted_text {
        out.push_str(&truncate_multiline(quoted_text, 480));
        out.push('\n');
    } else if !best.best_text.trim().is_empty() {
        out.push_str(&truncate_multiline(&best.best_text, 480));
        out.push('\n');
    } else {
        out.push_str("No direct text was recovered for this clipboard item.\n");
    }
    out.push('\n');
    let score = envelope.best_match_score.unwrap_or(0.0);
    let _ = writeln!(
        out,
        "Provenance: snapshot {}  observed {}  app {}  confidence {}  score {}",
        theme.id(best.snapshot_id),
        format_timestamp_short(&best.observed_at),
        best.app_name
            .as_deref()
            .or(best.app_bundle_id.as_deref())
            .unwrap_or("unknown app"),
        render_confidence(&envelope.best_match_confidence),
        theme.score(score, &format!("{:.1}%", score * 100.0))
    );
    let _ = writeln!(out, "Why: {}", envelope.why_selected);
    if !best.urls.is_empty() {
        let _ = writeln!(out, "URLs: {}", truncate_cell(&best.urls.join(", "), 88));
    }
    if !best.file_paths.is_empty() {
        let _ = writeln!(
            out,
            "Files: {}",
            truncate_cell(&best.file_paths.join(", "), 88)
        );
    }

    if !envelope.alternatives.is_empty() {
        out.push('\n');
        let _ = writeln!(out, "{}", theme.section("Alternatives"));
        out.push_str(&separator(WIDTH, false));
        out.push('\n');
        let _ = writeln!(
            out,
            "{:>3}  {:>7}  {:<14}  {:<18}  {:>7}  Preview",
            "#", "ID", "Seen", "App", "Score"
        );
        out.push_str(&separator(WIDTH, false));
        out.push('\n');
        for (idx, row) in envelope.alternatives.iter().take(10).enumerate() {
            let score_text = render_score_cell(&theme, row.score);
            let app = row
                .app_name
                .as_deref()
                .or(row.app_bundle_id.as_deref())
                .unwrap_or("unknown");
            let _ = writeln!(
                out,
                "{:>2}.  {:>7}  {:<14}  {:<18}  {}  {}",
                idx + 1,
                theme.id(row.snapshot_id),
                format_timestamp_short(&row.observed_at),
                truncate_cell(app, 18),
                score_text,
                truncate_cell(&row.snippet, 36)
            );
        }
    }

    out
}

pub(in crate::cli) fn render_unknown_list_human(command: &str, envelope: &ListEnvelope) -> String {
    let theme = HumanTheme::detect();
    let mut out = header(&theme, &format!("clipmem {command}"));
    push_meta(&mut out, envelope);
    out
}

pub(in crate::cli) fn header(theme: &HumanTheme, title: &str) -> String {
    let mut out = String::new();
    let _ = writeln!(out, "{}", theme.title(title));
    out.push_str(&separator(WIDTH.min(72), true));
    out.push('\n');
    out
}

pub(in crate::cli) fn push_meta(out: &mut String, envelope: &ListEnvelope) {
    let _ = writeln!(
        out,
        "Generated {}  Filters: {}  Results: {}",
        format_timestamp_short(&envelope.generated_at),
        render_filter_summary(&envelope.applied_filters),
        envelope.results.len()
    );
    if envelope.truncated {
        let cursor = envelope
            .next_cursor
            .as_deref()
            .map(|cursor| truncate_cell(cursor, 36))
            .unwrap_or_else(|| "unavailable".to_string());
        let _ = writeln!(
            out,
            "More results available. Cursor: {cursor} (use `--format json` for the full token)"
        );
    }
    out.push('\n');
}

pub(in crate::cli) fn push_snapshot_table(out: &mut String, theme: &HumanTheme, rows: &[ListRow]) {
    let has_score = rows.iter().any(|row| match row {
        ListRow::Snapshot(row) => human_score(row.score).is_some(),
        ListRow::Timeline(_) => false,
    });
    out.push_str(&separator(WIDTH, false));
    out.push('\n');
    if has_score {
        let _ = writeln!(
            out,
            "{:>3}  {:>7}  {:<14}  {:<16}  {:<7}  {:>8}  {:>8}  {:>7}  Preview",
            "#", "ID", "Seen", "App", "Kind", "Size", "Captures", "Score"
        );
    } else {
        let _ = writeln!(
            out,
            "{:>3}  {:>7}  {:<14}  {:<16}  {:<7}  {:>8}  {:>8}  Preview",
            "#", "ID", "Seen", "App", "Kind", "Size", "Captures"
        );
    }
    out.push_str(&separator(WIDTH, false));
    out.push('\n');

    for (idx, row) in rows.iter().enumerate() {
        let ListRow::Snapshot(row) = row else {
            continue;
        };
        let app = row
            .app_name
            .as_deref()
            .or(row.app_bundle_id.as_deref())
            .unwrap_or("unknown");
        let text = first_non_empty(&[&row.best_text, &row.preview_text, &row.text_summary]);
        if has_score {
            let score_text = render_score_cell(theme, row.score);
            let _ = writeln!(
                out,
                "{:>2}.  {:>7}  {:<14}  {:<16}  {:<7}  {:>8}  {:>8}  {}  {}",
                idx + 1,
                theme.id(row.snapshot_id),
                format_timestamp_short(&row.observed_at),
                truncate_cell(app, 16),
                row.kind,
                format_bytes(row.total_bytes as u64),
                format_count(row.capture_count),
                score_text,
                truncate_cell(text, 24)
            );
        } else {
            let _ = writeln!(
                out,
                "{:>2}.  {:>7}  {:<14}  {:<16}  {:<7}  {:>8}  {:>8}  {}",
                idx + 1,
                theme.id(row.snapshot_id),
                format_timestamp_short(&row.observed_at),
                truncate_cell(app, 16),
                row.kind,
                format_bytes(row.total_bytes as u64),
                format_count(row.capture_count),
                truncate_cell(text, 34)
            );
        }
    }
}

pub(in crate::cli) fn push_timeline_table(out: &mut String, theme: &HumanTheme, rows: &[ListRow]) {
    out.push_str(&separator(WIDTH, false));
    out.push('\n');
    let _ = writeln!(
        out,
        "{:>3}  {:>7}  {:>8}  {:<14}  {:<16}  {:<7}  {:>8}  Preview",
        "#", "Event", "Snapshot", "Seen", "App", "Kind", "Size"
    );
    out.push_str(&separator(WIDTH, false));
    out.push('\n');
    for (idx, row) in rows.iter().enumerate() {
        let ListRow::Timeline(row) = row else {
            continue;
        };
        let app = row
            .app_name
            .as_deref()
            .or(row.app_bundle_id.as_deref())
            .unwrap_or("unknown");
        let text = first_non_empty(&[&row.best_text, &row.preview_text, &row.text_summary]);
        let _ = writeln!(
            out,
            "{:>2}.  {:>7}  {:>8}  {:<14}  {:<16}  {:<7}  {:>8}  {}",
            idx + 1,
            theme.id(row.event_id),
            row.snapshot_id,
            format_timestamp_short(&row.observed_at),
            truncate_cell(app, 16),
            row.kind,
            format_bytes(row.total_bytes as u64),
            truncate_cell(text, 28)
        );
    }
}