goosedump 0.7.3

Coding agent context data browser
// SPDX-License-Identifier: LGPL-2.1-or-later
// Copyright (C) Jarkko Sakkinen 2026

use serde_json::Value;
use tabled::{
    builder::Builder,
    settings::{Color, Modify, Padding, Style, object::Rows},
};
use terminal_size::{Width, terminal_size};

use crate::index::{IndexEntry, format_id};
use crate::message::{ConversationMessage, MessageKind, ProviderId};
use crate::text;
use chrono::{DateTime, Utc};
use std::path::PathBuf;

struct SessionRow {
    id: u32,
    parent: Option<u32>,
    provider: String,
    label: Option<String>,
    cwd: PathBuf,
    count: u32,
    from: Option<DateTime<Utc>>,
    until: Option<DateTime<Utc>>,
}

impl From<&IndexEntry> for SessionRow {
    fn from(entry: &IndexEntry) -> Self {
        let ProviderId {
            label,
            cwd,
            count,
            from,
            until,
        } = &entry.provider_id;
        Self {
            id: entry.id,
            parent: entry.parent_id,
            provider: entry.provider.as_str().to_string(),
            label: label.clone(),
            cwd: cwd.clone(),
            count: *count,
            from: *from,
            until: *until,
        }
    }
}

struct Column {
    header: &'static str,
    values: Vec<String>,
    optional: bool,
}

impl Column {
    fn new(header: &'static str, optional: bool) -> Self {
        Self {
            header,
            values: Vec::new(),
            optional,
        }
    }

    fn push(&mut self, value: String) {
        self.values.push(value);
    }

    fn max_width(&self) -> usize {
        self.values
            .iter()
            .map(|v| v.chars().count())
            .max()
            .unwrap_or(0)
            .max(self.header.len())
    }

    fn has_data(&self) -> bool {
        self.values.iter().any(|v| !v.is_empty())
    }

    fn truncate(&mut self, width: usize) {
        for val in &mut self.values {
            if val.chars().count() > width {
                *val = truncate_middle(val, width);
            }
        }
    }
}

const COLUMNS: &[(&str, bool)] = &[
    ("ID", false),
    ("PARENT", false),
    ("PROVIDER", false),
    ("CWD", false),
    ("MSGS", false),
    ("LABEL", true),
    ("FROM", true),
    ("UNTIL", true),
];

pub fn print_sessions(
    entries: &[&IndexEntry],
    writer: &mut dyn std::io::Write,
    is_tty: bool,
) -> anyhow::Result<()> {
    if entries.is_empty() {
        return Ok(());
    }

    let rows: Vec<SessionRow> = entries.iter().map(|e| SessionRow::from(*e)).collect();

    let mut cols: Vec<Column> = COLUMNS
        .iter()
        .map(|(h, opt)| Column::new(h, *opt))
        .collect();

    for row in &rows {
        cols[0].push(format_id(row.id));
        cols[1].push(row.parent.map_or_else(String::new, format_id));
        cols[2].push(row.provider.clone());
        cols[3].push(row.cwd.display().to_string());
        cols[4].push(row.count.to_string());
        cols[5].push(row.label.clone().unwrap_or_default());
        cols[6].push(
            row.from
                .map_or_else(String::new, |dt| dt.format("%Y-%m-%d %H:%M").to_string()),
        );
        cols[7].push(
            row.until
                .map_or_else(String::new, |dt| dt.format("%Y-%m-%d %H:%M").to_string()),
        );
    }

    if is_tty {
        if let Some((Width(tw), _)) = terminal_size() {
            truncate_to_terminal(&mut cols, usize::from(tw));
        }
    }

    let visible: Vec<usize> = (0..cols.len())
        .filter(|&i| !cols[i].optional || cols[i].has_data())
        .collect();

    let mut builder = Builder::default();
    builder.push_record(visible.iter().map(|&i| cols[i].header).collect::<Vec<_>>());
    for row_idx in 0..rows.len() {
        let record: Vec<String> = visible
            .iter()
            .map(|&i| cols[i].values[row_idx].clone())
            .collect();
        builder.push_record(record);
    }

    let mut table = builder.build();
    table.with(Style::blank()).with(Padding::new(0, 2, 0, 0));
    if is_tty {
        table.with(Modify::new(Rows::first()).with(Color::BOLD));
    }

    writeln!(writer, "{table}")?;
    Ok(())
}

fn truncate_to_terminal(cols: &mut [Column], term_width: usize) {
    let visible: Vec<usize> = (0..cols.len())
        .filter(|&i| !cols[i].optional || cols[i].has_data())
        .collect();

    let n_visible = visible.len();
    let padding = 3 * n_visible;
    let fixed: usize = visible
        .iter()
        .filter(|&&i| i != 3 && i != 5)
        .map(|&i| cols[i].max_width())
        .sum();
    let available = term_width.saturating_sub(fixed + padding);

    if available == 0 {
        return;
    }

    let cwd_max = cols[3].max_width();
    let label_max = cols[5].max_width();

    if cwd_max + label_max <= available {
        return;
    }

    let cwd_w = cwd_max.min(available / 2);
    cols[3].truncate(cwd_w);
    let cwd_actual = cols[3].max_width();
    let label_w = available.saturating_sub(cwd_actual);
    cols[5].truncate(label_w);
}

fn truncate_middle(value: &str, width: usize) -> String {
    let len = value.chars().count();
    if len <= width {
        return value.to_string();
    }
    if width <= 3 {
        return ".".repeat(width);
    }

    let keep = width - 3;
    let head = keep / 2;
    let tail = keep - head;
    let prefix: String = value.chars().take(head).collect();
    let suffix: String = value
        .chars()
        .rev()
        .take(tail)
        .collect::<Vec<_>>()
        .into_iter()
        .rev()
        .collect();
    format!("{prefix}...{suffix}")
}

pub fn message_files(msg: &ConversationMessage) -> Vec<String> {
    match &msg.kind {
        MessageKind::AssistantResponse(ar) => ar
            .tool_calls
            .iter()
            .filter_map(|tc| path_argument(&tc.arguments))
            .collect(),
        _ => Vec::new(),
    }
}

pub fn searchable_text(msg: &ConversationMessage) -> String {
    match &msg.kind {
        MessageKind::TextContent(tc) => text::sanitize(&tc.text),
        MessageKind::AssistantResponse(ar) => {
            let mut parts: Vec<String> = ar.thinking.clone();
            if !ar.text.is_empty() {
                parts.push(ar.text.clone());
            }
            for tc in &ar.tool_calls {
                parts.push(format!(
                    "{} {}",
                    tc.name,
                    summarize_tool_args(&tc.arguments)
                ));
            }
            text::sanitize(&parts.join(" "))
        }
        MessageKind::ToolResultData(tr) => {
            if tr.content.is_empty() {
                text::sanitize(&tr.tool_name)
            } else {
                text::sanitize(&format!("{} {}", tr.tool_name, tr.content))
            }
        }
        MessageKind::BashOutput(bo) => {
            if bo.command.is_empty() {
                text::sanitize(&bo.output)
            } else if bo.output.is_empty() {
                text::sanitize(&bo.command)
            } else {
                text::sanitize(&format!("{} {}", bo.command, bo.output))
            }
        }
    }
}

pub fn summarize_tool_args(arguments: &Value) -> String {
    if let Some(obj) = arguments.as_object() {
        if let Some(path) = path_argument(arguments) {
            return format!("path={path}");
        }

        for key in &["command", "query", "pattern", "description"] {
            if let Some(val) = obj.get(*key) {
                if let Some(s) = val.as_str() {
                    return format!("{}={}", key, text::clip(s, 160));
                }
            }
        }

        if obj.is_empty() {
            return String::new();
        }

        let mut keys: Vec<&str> = obj.keys().map(std::string::String::as_str).collect();
        keys.sort_unstable();
        return keys.join(", ");
    }
    if let Some(s) = arguments.as_str() {
        return text::clip(s, 160);
    }
    if arguments.is_null() {
        return String::new();
    }
    text::clip(&arguments.to_string(), 160)
}

pub fn path_argument(arguments: &Value) -> Option<String> {
    let obj = arguments.as_object()?;
    for key in &["path", "file_path", "filePath", "file"] {
        if let Some(val) = obj.get(*key) {
            if let Some(s) = val.as_str() {
                return Some(s.to_string());
            }
        }
    }
    None
}