goosedump 0.1.0

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

use crate::message::{ContextListing, ConversationMessage, MessageKind, SearchHit};
use crate::text;
use serde_json::Value;
use std::fmt::Write;

const DISPLAY_WIDTH: usize = 80;

pub fn message_text(msg: &ConversationMessage, include_thinking: bool) -> String {
    let raw = match &msg.kind {
        MessageKind::TextContent(tc) => tc.text.clone(),
        MessageKind::AssistantResponse(ar) => {
            let mut parts: Vec<String> = Vec::new();
            if include_thinking {
                parts.extend(ar.thinking.clone());
            }
            if !ar.text.is_empty() {
                parts.push(ar.text.clone());
            }
            text::join_lines(&parts, "\n")
        }
        MessageKind::ToolResultData(tr) => tr.content.clone(),
        MessageKind::BashOutput(bo) => {
            let mut out = format!("$ {}", bo.command);
            if !bo.output.is_empty() {
                out.push('\n');
                out.push_str(&bo.output);
            }
            out
        }
    };
    text::sanitize(&raw)
}

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 {
    let raw = match &msg.kind {
        MessageKind::TextContent(tc) => tc.text.clone(),
        MessageKind::AssistantResponse(ar) => {
            let mut parts: Vec<String> = Vec::new();
            parts.extend(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::join_lines(&parts, " ")
        }
        MessageKind::ToolResultData(tr) => {
            if tr.content.is_empty() {
                tr.tool_name.clone()
            } else {
                format!("{} {}", tr.tool_name, tr.content)
            }
        }
        MessageKind::BashOutput(bo) => {
            if bo.command.is_empty() {
                bo.output.clone()
            } else if bo.output.is_empty() {
                bo.command.clone()
            } else {
                format!("{} {}", bo.command, bo.output)
            }
        }
    };
    text::sanitize(&raw)
}

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)
                && 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)
            && let Some(s) = val.as_str()
        {
            return Some(s.to_string());
        }
    }
    None
}

pub fn plain_contexts(contexts: &[ContextListing]) -> String {
    let mut out = String::from("Sessions:\n");
    for c in contexts {
        let path = c
            .path
            .as_ref()
            .map(|p| p.display().to_string())
            .unwrap_or_default();
        let _ = writeln!(out, "  {}  {}  {}", c.id, c.detail, path);
    }
    out.trim_end().to_string()
}

pub fn plain_messages(msgs: &[ConversationMessage]) -> String {
    let mut out = String::new();
    for (i, msg) in msgs.iter().enumerate() {
        if i > 0 {
            out.push('\n');
        }
        out.push_str(&plain_message(msg));
    }
    out.trim_end().to_string()
}

fn plain_message(msg: &ConversationMessage) -> String {
    match &msg.kind {
        MessageKind::TextContent(tc) => {
            let header = format!("entryId: {} ({})", msg.entry_id, tc.role);
            if tc.text.is_empty() {
                return header;
            }
            let body = text::wrap_text(&tc.text, DISPLAY_WIDTH, "  ");
            format!("{header}\n{body}")
        }
        MessageKind::AssistantResponse(ar) => {
            let header = format!("entryId: {} (assistant)", msg.entry_id);
            let mut body = String::new();

            if !ar.thinking.is_empty() {
                body.push_str("  thinking:\n");
                for t in &ar.thinking {
                    body.push_str(&text::wrap_text(t, DISPLAY_WIDTH, "    "));
                    body.push('\n');
                }
            }

            if !ar.text.is_empty() {
                body.push_str("  text:\n");
                body.push_str(&text::wrap_text(&ar.text, DISPLAY_WIDTH, "    "));
                body.push('\n');
            }

            if !ar.tool_calls.is_empty() {
                body.push_str("  tool_calls:\n");
                for tc in &ar.tool_calls {
                    let label = format!("{}: {}", tc.name, summarize_tool_args(&tc.arguments));
                    body.push_str(&text::wrap_text(&label, DISPLAY_WIDTH, "    "));
                    body.push('\n');
                }
            }

            body = body.trim_end().to_string();
            if body.is_empty() {
                header
            } else {
                format!("{header}\n{body}")
            }
        }
        MessageKind::ToolResultData(tr) => {
            let error = if tr.is_error { "" } else { "" };
            let header = format!("entryId: {} (tool/{}{})", msg.entry_id, tr.tool_name, error);
            let body = text::wrap_text(&tr.content, DISPLAY_WIDTH, "  ");
            format!("{header}\n{body}")
        }
        MessageKind::BashOutput(bo) => {
            let header = format!("entryId: {} (bash)", msg.entry_id);
            let cmd = format!("$ {}", bo.command);
            let body = if bo.output.is_empty() {
                text::wrap_text(&cmd, DISPLAY_WIDTH, "  ")
            } else {
                let combined = format!("{}\n{}", cmd, bo.output);
                text::wrap_text(&combined, DISPLAY_WIDTH, "  ")
            };
            format!("{header}\n{body}")
        }
    }
}

pub fn plain_compact(msgs: &[ConversationMessage]) -> String {
    let mut out = String::new();
    for msg in msgs {
        let body = message_text(msg, false);

        let header;
        let mut lines: Vec<String> = Vec::new();

        match &msg.kind {
            MessageKind::TextContent(tc) => {
                if body.is_empty() {
                    continue;
                }
                header = format!("entryId: {} ({}/text)", msg.entry_id, tc.role);
                lines.push(text::clip(&body, 60));
            }
            MessageKind::AssistantResponse(ar) => {
                if body.is_empty() && ar.tool_calls.is_empty() {
                    continue;
                }
                header = format!("entryId: {} (assistant)", msg.entry_id);
                if !ar.tool_calls.is_empty() {
                    let calls: Vec<String> = ar
                        .tool_calls
                        .iter()
                        .map(|tc| {
                            let args = summarize_tool_args(&tc.arguments);
                            if args.is_empty() {
                                tc.name.clone()
                            } else {
                                format!("{}({})", tc.name, text::clip(&args, 60))
                            }
                        })
                        .collect();
                    let calls_str = format!("tools: {}", calls.join(", "));
                    lines.push(text::clip(&calls_str, 70));
                }
                if !body.is_empty() {
                    lines.push(text::clip(&body, 60));
                }
            }
            MessageKind::ToolResultData(tr) => {
                if body.is_empty() {
                    continue;
                }
                let error = if tr.is_error { "" } else { "" };
                header = format!("entryId: {} (tool/{}{})", msg.entry_id, tr.tool_name, error);
                lines.push(text::clip(&body, 60));
            }
            MessageKind::BashOutput(bo) => {
                if bo.command.is_empty() && bo.output.is_empty() {
                    continue;
                }
                header = format!("entryId: {} (bash)", msg.entry_id);
                lines.push(text::clip(&body, 60));
            }
        }
        out.push_str(&header);
        out.push('\n');
        for line in &lines {
            let _ = writeln!(out, "  {line}");
        }
        out.push('\n');
    }
    out.trim_end().to_string()
}

pub fn plain_compact_hits(hits: &[SearchHit]) -> String {
    let mut out = String::new();
    for h in hits {
        let _ = writeln!(out, "entryId: {} ({})", h.entry_id, h.role);
        let _ = writeln!(out, "  score: {:.2}", h.score);
        let first_line = h.text.lines().next().unwrap_or("");
        if !first_line.is_empty() {
            let _ = writeln!(out, "  {}", text::clip(first_line, 70));
        }
        out.push('\n');
    }
    out.trim_end().to_string()
}

pub fn plain_hits(hits: &[SearchHit]) -> String {
    let mut out = String::new();
    for h in hits {
        let header = format!("entryId: {} ({})", h.entry_id, h.role);
        out.push_str(&header);
        out.push('\n');
        let _ = writeln!(out, "  score: {:.2}", h.score);
        if !h.files.is_empty() {
            let _ = writeln!(out, "  files: {}", h.files.join(", "));
        }
        if !h.text.is_empty() {
            out.push_str(&text::wrap_text(&h.text, DISPLAY_WIDTH, "  "));
            out.push('\n');
        }
        out.push('\n');
    }
    out.trim_end().to_string()
}