goosedump 0.7.0

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

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

use crate::index::{IndexEntry, format_id};
use crate::message::{ConversationMessage, MessageKind};
use crate::text;

#[derive(Tabled)]
struct SessionRow {
    #[tabled(rename = "ID")]
    id: String,
    #[tabled(rename = "PARENT")]
    parent: String,
    #[tabled(rename = "PROVIDER")]
    provider: String,
    #[tabled(rename = "TIMESTAMP")]
    timestamp: String,
    #[tabled(rename = "CWD")]
    cwd: String,
    #[tabled(rename = "MSGS")]
    count: String,
    #[tabled(rename = "NAME")]
    name: String,
    #[tabled(rename = "TIME")]
    time: String,
}

pub fn print_table<T>(
    items: &[T],
    writer: &mut dyn std::io::Write,
    is_tty: bool,
) -> anyhow::Result<()>
where
    T: Tabled,
{
    if items.is_empty() {
        return Ok(());
    }

    let mut table = Table::new(items);
    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(())
}

pub fn print_sessions(
    entries: &[&IndexEntry],
    writer: &mut dyn std::io::Write,
    is_tty: bool,
) -> anyhow::Result<()> {
    let mut rows: Vec<SessionRow> = entries
        .iter()
        .map(|entry| SessionRow {
            id: format_id(entry.id),
            parent: entry.parent_id.map_or_else(|| "-".to_string(), format_id),
            provider: entry.provider.as_str().to_string(),
            timestamp: display_cell(entry.provider_id.timestamp()),
            cwd: display_cell(entry.provider_id.cwd()),
            count: entry.provider_id.count(),
            name: display_cell(entry.provider_id.name()),
            time: entry.provider_id.time_range(),
        })
        .collect();
    if let Some(width) = cwd_width(&rows, is_tty) {
        for row in &mut rows {
            row.cwd = truncate_middle(&row.cwd, width);
        }
    }
    print_table(&rows, writer, is_tty)
}

fn display_cell(value: &str) -> String {
    if value.is_empty() {
        "-".to_string()
    } else {
        value.to_string()
    }
}

fn cwd_width(rows: &[SessionRow], is_tty: bool) -> Option<usize> {
    if !is_tty {
        return None;
    }

    let (Width(width), _) = terminal_size()?;
    let fixed = column_width(rows, "ID", |row| &row.id)
        + column_width(rows, "PARENT", |row| &row.parent)
        + column_width(rows, "PROVIDER", |row| &row.provider)
        + column_width(rows, "TIMESTAMP", |row| &row.timestamp)
        + column_width(rows, "MSGS", |row| &row.count)
        + column_width(rows, "NAME", |row| &row.name)
        + column_width(rows, "TIME", |row| &row.time)
        + 16;
    Some(usize::from(width).saturating_sub(fixed).max(12))
}

fn column_width(rows: &[SessionRow], header: &str, value: impl Fn(&SessionRow) -> &str) -> usize {
    rows.iter()
        .map(|row| value(row).chars().count())
        .max()
        .unwrap_or(0)
        .max(header.len())
}

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(&text::join_lines(&parts, " "))
        }
        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
}