polyc-tui 2026.8.3

Operator cockpit TUI (pc-tui) for the polychrome control plane: fleet, transcript, approvals, and tools panes.
//! Transcript data adapter.
//!
//! Projects the forensics `/api/conversations/{id}/transcript` projection
//! (poll-only, off-by-default server) into [`MessageLine`]s for prior turns,
//! plus the matching `/usage` projection for the token ledger.
//!
//! Everything here is data-source-agnostic: the projections produce plain
//! [`MessageLine`]s and the spawn helper feeds them to the loop as `Action`s.
//! Rendering stays entirely in the component, so the transcript view works
//! identically against a live control plane or a local debug fixture.

use anyhow::Result;
use serde::Deserialize;
use tokio::sync::mpsc::UnboundedSender;

use crate::action::Action;
use crate::components::transcript::{LineKind, MessageLine};

/// Serde mirror of the forensics `TranscriptResponse` (`snake_case` keys).
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct TranscriptResponse {
    /// Rendered transcript entries, oldest first.
    pub entries: Vec<TranscriptEntry>,
}

/// Serde mirror of the forensics `TranscriptEntry`.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct TranscriptEntry {
    /// One of `user`/`model`/`tool`/`system`.
    pub role: String,
    /// The rendered text of the entry.
    pub text: String,
}

/// Serde mirror of the forensics `UsageResponse` (`snake_case` keys).
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct UsageResponse {
    /// Total prompt-side tokens across the conversation.
    pub input_tokens: u64,
    /// Total completion-side tokens across the conversation.
    pub output_tokens: u64,
    /// Per-turn breakdown in event-log order.
    #[serde(default)]
    pub per_turn: Vec<PerTurnUsage>,
}

/// Serde mirror of the forensics `PerTurnUsage`. The per-turn breakdown is
/// currently summarised only by its length (turn count) in the ledger line, so
/// the per-turn token fields are not modelled here.
#[derive(Debug, Clone, Deserialize)]
pub(crate) struct PerTurnUsage {}

/// Load prior transcript history for `conversation_id` from the forensics
/// server at `forensics_base_url`, projected to [`MessageLine`]s.
///
/// Appends a trailing [`LineKind::System`] usage-ledger line when the matching
/// `/usage` projection is reachable; a missing/empty usage projection is not an
/// error (the ledger is simply omitted).
///
/// # Errors
/// Returns an error if the transcript HTTP request fails or the body cannot be
/// decoded.
pub(crate) async fn load_history(
    forensics_base_url: &str,
    conversation_id: &str,
) -> Result<Vec<MessageLine>> {
    let base = forensics_base_url.trim_end_matches('/');
    let client = crate::data::http_client();

    let url = format!("{base}/api/conversations/{conversation_id}/transcript");
    let resp: TranscriptResponse = client
        .get(&url)
        .send()
        .await?
        .error_for_status()?
        .json()
        .await?;

    let mut lines: Vec<MessageLine> = resp.entries.iter().map(line_from_entry).collect();

    // Usage ledger is best-effort enrichment: a forensics server with usage
    // tracking off (or a conversation with no usage yet) must not fail history.
    if let Some(line) = load_usage_line(base, conversation_id).await {
        lines.push(line);
    }

    Ok(lines)
}

/// Fetch the forensics usage projection and render it as a single
/// [`LineKind::System`] ledger line. Returns `None` on any error or when no
/// tokens have been recorded.
async fn load_usage_line(base: &str, conversation_id: &str) -> Option<MessageLine> {
    let url = format!("{base}/api/conversations/{conversation_id}/usage");
    let usage: UsageResponse = crate::data::http_client()
        .get(&url)
        .send()
        .await
        .ok()?
        .error_for_status()
        .ok()?
        .json()
        .await
        .ok()?;
    Some(usage_ledger_line(&usage))
}

/// Render a [`UsageResponse`] into a system ledger line.
#[must_use]
pub(crate) fn usage_ledger_line(usage: &UsageResponse) -> MessageLine {
    let turns = usage.per_turn.len();
    MessageLine {
        kind: LineKind::System,
        text: format!(
            "usage ledger: input={} output={} total={} over {} turn(s)",
            usage.input_tokens,
            usage.output_tokens,
            usage.input_tokens + usage.output_tokens,
            turns,
        ),
    }
}

/// Spawn a background task that backfills history and feeds it to the loop as a
/// single [`Action::TranscriptHistory`]; failures become [`Action::Error`].
pub(crate) fn spawn_history(
    forensics_base_url: String,
    conversation_id: String,
    tx: UnboundedSender<Action>,
) {
    tokio::spawn(async move {
        match load_history(&forensics_base_url, &conversation_id).await {
            Ok(lines) => {
                let _ = tx.send(Action::TranscriptHistory {
                    conversation_id,
                    lines,
                });
            }
            Err(err) => {
                let _ = tx.send(Action::Error(format!("transcript history: {err}")));
            }
        }
    });
}

/// Project a forensics [`TranscriptEntry`] into a [`MessageLine`].
#[must_use]
pub(crate) fn line_from_entry(entry: &TranscriptEntry) -> MessageLine {
    let kind = match entry.role.as_str() {
        "user" => LineKind::User,
        "tool" => LineKind::ToolResult {
            call_id: String::new(),
        },
        "thought" => LineKind::Thought,
        "system" => LineKind::System,
        // "model"/"assistant" and any unknown role render as assistant prose.
        _ => LineKind::Assistant,
    };
    MessageLine {
        kind,
        text: entry.text.clone(),
    }
}