csift 0.12.2

ripgrep for Claude Code session transcripts: fast regex list/search over ~/.claude/projects/**/*.jsonl
//! Text + JSON projections for status/wait.

use super::*;
use serde_json::json;

pub(crate) fn render_status_text(session_id: &str, a: &Assessment) {
    println!("STATUS  {session_id}");
    println!("verdict  {}", a.verdict.slug());
    println!();
    for e in &a.evidence {
        let age = e
            .age_secs
            .map(|s| format!("  ({s}s ago)"))
            .unwrap_or_default();
        println!("  {:<9} {}{age}", e.surface, e.value);
    }
    let (live, settled): (Vec<_>, Vec<_>) = a.children.iter().partition(|c| c.state != "settled");
    for c in live {
        println!("  child     {}  {}  {}", c.session_id, c.state, c.detail);
    }
    if !settled.is_empty() {
        println!(
            "  child     {} settled lane(s) folded (ids: csift agents @{session_id})",
            settled.len()
        );
    }
    // Task ROWS need a task; the STORE line does not. A directory that answered is a
    // fact about where this list came from, and an empty one answers that question
    // exactly as an occupied one does - so it prints either way and says it is empty,
    // rather than leaving the text surface silent about a store the JSON reports.
    let has_tasks = !a.tasks.open.is_empty() || a.tasks.completed > 0;
    if has_tasks {
        for t in &a.tasks.open {
            let blocked = if t.blocked_by.is_empty() {
                String::new()
            } else {
                format!("  (blocked by #{})", t.blocked_by.join(", #"))
            };
            println!(
                "  task      #{} {}  {}{blocked}",
                t.id,
                t.status,
                crate::text::collapse_and_truncate(&t.subject, 200)
            );
        }
    }
    // Which directory answered, and which candidate named it: a store found through
    // anything but the session's own id was found by inference.
    for st in &a.tasks.stores {
        let empty = if has_tasks { "" } else { "  - empty" };
        println!("  tasks     store: {} (via {}){empty}", st.dir, st.via);
    }
    if has_tasks {
        println!(
            "  tasks     {} open ; {} completed",
            a.tasks.open.len(),
            a.tasks.completed
        );
    }
    render_background_text(&a.background);
    render_last_text(session_id, &a.last);
    for n in a.notes.iter().chain(a.background.notes.iter()) {
        println!("  note: {n}");
    }
}

/// The background section: every OPEN task (counted first, then ignored), one row each;
/// closed ones are folded into the evidence row's counts.
pub(crate) fn render_background_text(b: &BackgroundReport) {
    for t in b.tasks.iter().filter(|t| t.is_open()) {
        let launched = t
            .launched_utc
            .as_deref()
            .map(|ts| {
                let age = age_secs(Some(ts))
                    .map(|s| {
                        format!(
                            " ({} ago)",
                            crate::text::fmt_secs(u64::try_from(s).unwrap_or(0))
                        )
                    })
                    .unwrap_or_default();
                format!("launched {}{age}", crate::timez::format_timestamp(Some(ts)))
            })
            .unwrap_or_else(|| "launched (no timestamp)".to_string());
        let what = t
            .description
            .as_deref()
            .or(t.command.as_deref())
            .map(|d| format!("  \"{}\"", crate::text::collapse_and_truncate(d, 80)))
            .unwrap_or_default();
        let output = match (t.output_bytes, t.output_age_secs) {
            (Some(bytes), Some(age)) => format!(
                "  output {} B, last write {} ago",
                bytes,
                crate::text::fmt_secs(u64::try_from(age).unwrap_or(0))
            ),
            (Some(bytes), None) => format!("  output {bytes} B"),
            _ => String::new(),
        };
        // The three harness-side entrances say so: a shell the model ran in the
        // FOREGROUND is a background task now, and nothing else on the row shows it.
        let entered = t
            .entered_by
            .and_then(BgEntrance::label)
            .map(|l| match t.timed_out_after_ms {
                Some(ms) if ms > 0 => format!(
                    "  {l} after {}",
                    crate::text::fmt_secs(u64::try_from(ms / 1000).unwrap_or(0))
                ),
                _ => format!("  {l}"),
            })
            .unwrap_or_default();
        let launch_note = t
            .launch_note
            .as_deref()
            .map(|n| format!("  [{n}]"))
            .unwrap_or_default();
        let ignored = t
            .ignored_by
            .as_deref()
            .map(|r| format!("  [ignored: {r}]"))
            .unwrap_or_default();
        // A launch from a subagent lane names the lane (a bare agent id); the main
        // session's own uuid is the row's context already.
        let is_uuid = t.lane.len() == 36 && t.lane.matches('-').count() == 4;
        let lane = if is_uuid {
            String::new()
        } else {
            format!("  lane {}", t.lane)
        };
        println!(
            "  bg        {:<7} {:<18} {launched}{entered}{launch_note}{what}{output}{ignored}{lane}",
            t.kind.slug(),
            t.id.as_deref().unwrap_or(&t.tool_use_id)
        );
    }
}

/// The `last` section: newest prompt + newest assistant message, as excerpts, with the
/// refetch and the warning that an excerpt is not a review.
pub(crate) fn render_last_text(session_id: &str, last: &LastMessages) {
    let row = |glyph: &str, m: &LastMsg| {
        let ts = crate::timez::format_timestamp(m.ts_utc.as_deref());
        println!("  last {glyph}    {ts}  {}", m.text);
    };
    if let Some(u) = &last.user {
        row("â—‚", u);
    }
    if let Some(g) = &last.agent {
        row("â–¸", g);
    }
    if last.user.is_some() || last.agent.is_some() {
        println!(
            "  note: the last-message excerpts are a partial view of the final state, never a \
             review of the work (whole turn: csift show @{session_id} --turn -1)"
        );
    }
}

pub(crate) fn background_json(b: &BackgroundReport) -> serde_json::Value {
    let (c, f, k, s, t) = b.closed_counts();
    let (blocked, other) = b.rare_counts();
    json!({
        "open": b.open_counted(),
        "ignored": b.open_ignored(),
        "completed": c,
        "failed": f,
        "killed": k,
        "stopped": s,
        "timed_out": t,
        "blocked": blocked,
        "other": other,
        "scanned_files": b.scanned_files,
        "tasks": b.tasks.iter().filter(|t| t.is_open()).map(|t| json!({
            "kind": t.kind.slug(),
            "id": t.id,
            "tool_use_id": t.tool_use_id,
            "lane": t.lane,
            "state": t.state.slug(),
            "entered_by": t.entered_by.map(BgEntrance::slug),
            "timed_out_after_ms": t.timed_out_after_ms,
            "launch_note": t.launch_note,
            "description": t.description,
            "command": t.command,
            "launched_utc": t.launched_utc,
            "launched_local": t.launched_utc.as_deref().and_then(crate::timez::local_iso),
            "age_secs": age_secs(t.launched_utc.as_deref()),
            "output_file": t.output_file,
            "output_bytes": t.output_bytes,
            "output_age_secs": t.output_age_secs,
            "ignored_by": t.ignored_by,
        })).collect::<Vec<_>>(),
        "notes": b.notes,
    })
}

/// The tail checkpoint as a machine row: the physical line and the line type, which is
/// all the line carries (it has no uuid, no timestamp and no message).
pub(crate) fn checkpoint_json(cp: Option<&CheckpointTail>) -> serde_json::Value {
    match cp {
        Some(c) => json!({ "line": c.line, "kind": c.kind }),
        None => serde_json::Value::Null,
    }
}

pub(crate) fn last_json(last: &LastMessages) -> serde_json::Value {
    let one = |m: &Option<LastMsg>| match m {
        Some(m) => json!({
            "ts_utc": m.ts_utc,
            "ts_local": m.ts_utc.as_deref().and_then(crate::timez::local_iso),
            "text": m.text,
            "truncated": m.truncated,
        }),
        None => serde_json::Value::Null,
    };
    json!({ "user": one(&last.user), "agent": one(&last.agent) })
}

pub(crate) fn render_status_json(
    session_id: &str,
    is_subagent: bool,
    parent_session_id: &str,
    a: &Assessment,
) -> Result<()> {
    let header = crate::text::envelope_header(
        "status",
        json!({
            "session_id": session_id,
            "is_subagent": is_subagent,
            "parent_session_id": parent_session_id,
        }),
    );
    println!("{}", serde_json::to_string(&header)?);
    let row = json!({
        "kind": "verdict",
        "verdict": a.verdict.slug(),
        "evidence": a.evidence.iter().map(|e| json!({
            "surface": e.surface,
            "value": e.value,
            "age_secs": e.age_secs,
        })).collect::<Vec<_>>(),
        "children": a.children.iter().filter(|c| c.state != "settled").map(|c| json!({
            "session_id": c.session_id,
            "state": c.state,
            "detail": c.detail,
        })).collect::<Vec<_>>(),
        "settled_children": a.children.iter().filter(|c| c.state == "settled").count(),
        "tasks": if a.tasks.found {
            json!(a.tasks.open.iter().map(|t| json!({
                "id": t.id,
                "subject": t.subject,
                "status": t.status,
                "blocked_by": t.blocked_by,
            })).collect::<Vec<_>>())
        } else {
            serde_json::Value::Null
        },
        "tasks_completed": if a.tasks.found { json!(a.tasks.completed) } else { serde_json::Value::Null },
        "tasks_stores": a.tasks.stores.iter().map(|s| json!({
            "dir": s.dir,
            "via": s.via,
        })).collect::<Vec<_>>(),
        "last_checkpoint": checkpoint_json(a.last_checkpoint.as_ref()),
        "pending": a.pending,
        "background": background_json(&a.background),
        "last": last_json(&a.last),
        "tail_state": a.tail_state,
        "notes": a.notes.iter().chain(a.background.notes.iter()).collect::<Vec<_>>(),
    });
    println!("{}", serde_json::to_string(&row)?);
    let summary = crate::text::envelope_summary(json!({"verdict": a.verdict.slug()}));
    println!("{}", serde_json::to_string(&summary)?);
    Ok(())
}